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>
This commit is contained in:
121
docs/agent-tasks/04-one-input-policy.md
Normal file
121
docs/agent-tasks/04-one-input-policy.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user