# 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.