Files
musicmouse/docs/agent-tasks/04-one-input-policy.md
Martin Bauer 4cb2d085ca Add agent task files for the review's remaining fixes
Authentication (01), one input policy for skip/mute/volume steps (04), the
firmware's interleaved serial frames (05), podcast as an album kind (06), and eight
smaller cleanups (07). Each says why, what was decided, the design, the tests and
when it is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:27 +02:00

7.0 KiB
Raw Blame History

Task: one behaviour per button, whatever it is pressed on

Why

The backend's README promises that front-ends only translate input into intents and that musicmouse/reactions/ is the one place that decides what happens. Three behaviours break that promise today, so the same request does different things depending on where it came from:

Behaviour Web front-end IR remote Mouse buttons / knob MQTT
Next/previous on a podcast seeks ±30 s (web/src/App.tsx, skipPodcast) skips a track skips a track skips a track
Unmute jumps to 60 % (UNMUTE_PERCENT in App.tsx) restores the previous volume (LircService._toggle_mute) - -
Volume step J/K: 10 % (VOLUME_STEP in web/src/lib/keyboard.ts); +/- buttons: volume_increment volume_increment volume_increment × detents absolute only

There is also a confirmed bug. With min_volume > 0, the remote's mute never unmutes: "mute" asks for volume 0, the player clamps it to min_volume, the next press sees volume > 0 and mutes again.

What the owner decided

  1. Next/previous on a podcast episode always seeks 30 s forward/back, from every input: web (buttons and keys), IR remote, the mouse's buttons, MQTT.
  2. Unmute always restores the volume from before muting, from every input.
  3. Parent mode gets three separate volume-step settings: the web keyboard's J/K, the mouse's rotary knob, and the IR remote. Each is configured independently.

Design

1. Podcast skip lives in the backend

  • In musicmouse/reactions/playback.py, next_track / previous_track look at the current album (app.album_for(app.player.playlist)). For a podcast episode they seek instead: position ± PODCAST_SKIP_SECONDS (30, a module constant), clamped to [0, duration]. Take the duration from the library track when the player does not know it yet, the way services/web/state.py does.
  • "Is this a podcast?" is decided by one helper. If docs/agent-tasks/06-podcast-kind.md has landed, that is album.kind == "podcast". If not, add is_podcast(album) next to Album (musicmouse/library/models.py) that checks the section's album_unit == "episode", and let task 06 change its body.
  • web/src/App.tsx: the next / previous actions always call api.next() / api.previous(). Delete skipPodcast and PODCAST_SKIP_SECONDS. Drop the optimistic position: 0 patch too: the client no longer knows what the backend will do. If the ≤0.5 s until the next position frame is visibly laggy, let StateHub push a position frame right after a next/previous/seek intent has been handled. Don't re-add client-side policy.
  • The mouse's left/right buttons only act while playing (button_pressed); keep that.

2. Mute is a player state, not a volume

  • Add muted: bool to the Player protocol, plus set_muted(muted, *, source), implemented in PlayerBase. VlcPlayer applies it with libVLC's audio_set_mute; FakePlayer just records it. Muting does not touch volume, so unmuting restores it by construction, and min_volume cannot interfere.
  • Announce changes with a new StateEvent (for example MuteChanged) and add it to SNAPSHOT_EVENTS in services/web/hub.py. A test fails until every StateEvent is sorted into that list or IGNORED_EVENTS.
  • A new intent, ToggleMuteRequested, handled by a reaction. Any volume change (SetVolumeRequested, VolumeChangeRequested, the step intent below) also unmutes; that is what every TV remote does.
  • POST /api/mute/toggle. The web's Shift+M and the mute button call it. Delete UNMUTE_PERCENT and onMute's volume arithmetic.
  • LircService: KEY_MUTE emits ToggleMuteRequested. Delete _pre_mute_volume and _toggle_mute.
  • The state snapshot (PlayerStateOut, web/src/api/types.ts) gets muted. The volume bars show muted distinctly instead of showing volume 0.
  • MQTT is out of scope. Its volume number stays absolute, and setting it unmutes via the rule above.

3. Three volume steps

  • Config: replace general.volume_increment with general.volume_steps: {keyboard: int, knob: int, remote: int}, all in device units like min_volume / max_volume, each ge=1, le=100. Defaults: keyboard: 10, knob: 5, remote: 5 (today's behaviour for knob and remote).
  • volume_increment in an existing config.yml must fail with a message naming the replacement ("replaced by volume_steps.knob / .remote / .keyboard"). Config validation rejects unknown keys anyway; make the message specific rather than the generic "unknown option".
  • One intent: VolumeStepRequested(direction: Literal[-1, 1], count: int = 1, input: Literal["keyboard", "knob", "remote"]). One reaction multiplies by the configured step and calls change_volume.
    • rotary_turned emits it with input="knob", count=abs(event.increment).
    • LircService volume keys emit it with input="remote".
    • POST /api/volume takes {"steps": n, "input": "keyboard" | "remote"} instead of bare steps. J/K send input: "keyboard". The on-screen −/+ buttons keep taking the remote's step, as 1254790 deliberately chose, so they send input: "remote".
    • {"percent": n} stays for the volume bars.
  • Parent mode: SettingsIn / SettingsOut (services/web/schemas.py), read_settings / write_settings (services/web/settings.py, writing the nested mapping), and web/src/components/ParentPanel.tsx get three fields, labelled in German (for example "Lautstärke-Schritt: Tastatur (J/K)", "… Drehknopf", "… Fernbedienung"). They replace the single "rotary step". Saving applies live, like the other settings.
  • Update python-backend/config.yml.example, the route table in python-backend/README.md, web/README.md's key table, and HelpOverlay.tsx.

Tests

  • Reactions (tests/test_reactions.py, through the simulator):
    • Next/previous on a podcast episode seeks ±30 s and clamps at both ends. Use a Kinderpodcasts folder in the test library (see tests/conftest.py).
    • Next/previous on music still changes track.
    • Mute then unmute restores the exact volume, including with min_volume > 0 (the regression).
    • A volume change while muted unmutes.
    • Each of the three inputs moves by its own configured step.
  • tests/test_lirc.py: the mute key and the volume keys emit the new intents.
  • tests/test_web.py: /api/mute/toggle; /api/volume with input; the settings round-trip for volume_steps, which keeps the file's comments.
  • tests/test_config.py: the defaults, and the specific error for volume_increment.
  • web/src/lib/__tests__/keyboard.test.ts: J/K produce the step action, not a percentage.

Done when

scripts/check.sh passes, grep -rn "PODCAST_SKIP\|UNMUTE_PERCENT\|volume_increment" finds nothing in web/src and python-backend/musicmouse apart from the config error that names the old key, and in --simulate a podcast skips 30 s from the web, from press right in the REPL, and from a simulated remote key.