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>
7.0 KiB
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
- 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.
- Unmute always restores the volume from before muting, from every input.
- 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_tracklook 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 wayservices/web/state.pydoes. - "Is this a podcast?" is decided by one helper. If
docs/agent-tasks/06-podcast-kind.mdhas landed, that isalbum.kind == "podcast". If not, addis_podcast(album)next toAlbum(musicmouse/library/models.py) that checks the section'salbum_unit == "episode", and let task 06 change its body. web/src/App.tsx: thenext/previousactions always callapi.next()/api.previous(). DeleteskipPodcastandPODCAST_SKIP_SECONDS. Drop the optimisticposition: 0patch too: the client no longer knows what the backend will do. If the ≤0.5 s until the next position frame is visibly laggy, letStateHubpush apositionframe 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: boolto thePlayerprotocol, plusset_muted(muted, *, source), implemented inPlayerBase.VlcPlayerapplies it with libVLC'saudio_set_mute;FakePlayerjust records it. Muting does not touchvolume, so unmuting restores it by construction, andmin_volumecannot interfere. - Announce changes with a new
StateEvent(for exampleMuteChanged) and add it toSNAPSHOT_EVENTSinservices/web/hub.py. A test fails until everyStateEventis sorted into that list orIGNORED_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. DeleteUNMUTE_PERCENTandonMute's volume arithmetic.LircService:KEY_MUTEemitsToggleMuteRequested. Delete_pre_mute_volumeand_toggle_mute.- The state snapshot (
PlayerStateOut,web/src/api/types.ts) getsmuted. 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_incrementwithgeneral.volume_steps: {keyboard: int, knob: int, remote: int}, all in device units likemin_volume/max_volume, eachge=1, le=100. Defaults:keyboard: 10,knob: 5,remote: 5(today's behaviour for knob and remote). volume_incrementin an existingconfig.ymlmust 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 callschange_volume.rotary_turnedemits it withinput="knob",count=abs(event.increment).LircServicevolume keys emit it withinput="remote".POST /api/volumetakes{"steps": n, "input": "keyboard" | "remote"}instead of baresteps. J/K sendinput: "keyboard". The on-screen −/+ buttons keep taking the remote's step, as1254790deliberately chose, so they sendinput: "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), andweb/src/components/ParentPanel.tsxget 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 inpython-backend/README.md,web/README.md's key table, andHelpOverlay.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
Kinderpodcastsfolder in the test library (seetests/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.
- Next/previous on a podcast episode seeks ±30 s and clamps at both ends. Use a
tests/test_lirc.py: the mute key and the volume keys emit the new intents.tests/test_web.py:/api/mute/toggle;/api/volumewithinput; the settings round-trip forvolume_steps, which keeps the file's comments.tests/test_config.py: the defaults, and the specific error forvolume_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.