From 12547903740cd79c8afb6ab02bd58970a6d356e9 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sat, 3 Oct 2026 19:44:07 +0200 Subject: [PATCH] Volume: quieter/louder buttons that take the remote's own step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next to the five volume bars, a − and a + button move the volume by `volume_increment` device units, the same step as the IR remote's volume keys and the rotary encoder, via a new `{steps}` form of POST /api/volume. The bars still jump to a percentage. Co-Authored-By: Claude Opus 5.5 --- python-backend/README.md | 2 +- python-backend/musicmouse/services/web/api.py | 10 +++- .../musicmouse/services/web/schemas.py | 8 ++- python-backend/tests/test_web.py | 18 ++++++- web/src/App.tsx | 8 +++ web/src/api/client.ts | 2 + web/src/components/PlayView.tsx | 4 ++ web/src/components/PlayerBar.tsx | 4 ++ web/src/components/VolumeBars.tsx | 51 ++++++++++++++++++- 9 files changed, 101 insertions(+), 6 deletions(-) diff --git a/python-backend/README.md b/python-backend/README.md index 31256ac..7dd4ea7 100644 --- a/python-backend/README.md +++ b/python-backend/README.md @@ -216,7 +216,7 @@ copied to the device. | `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. | | `POST /api/play` | `{album_id, track_index?}` | | `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport | -| `POST /api/volume` | `{percent}` or `{delta_percent}` | +| `POST /api/volume` | `{percent}`, `{delta_percent}`, or `{steps}` - the IR remote's step (`volume_increment` device units) per step | | `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits | | `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show | | `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off | diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index 39a3ae7..8c60398 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -33,6 +33,7 @@ from musicmouse.events import ( SetVolumeRequested, ToggleScreenRequested, UserActivity, + VolumeChangeRequested, ) from musicmouse.services.web.hub import StateHub from musicmouse.services.web.remote_settings import read_mapping, write_mapping @@ -212,12 +213,19 @@ def build_router( @router.post("/volume", status_code=204) def set_volume(body: VolumeIn) -> Response: general = app.config.general + if body.steps is not None: + # The IR remote's own step, in device units - see LircService._handle. + return emit( + VolumeChangeRequested(delta=general.volume_increment * body.steps, source="web") + ) if body.percent is not None: target = body.percent elif body.delta_percent is not None: target = to_percent(app.player.volume, general) + body.delta_percent else: - raise HTTPException(status_code=422, detail="percent or delta_percent required") + raise HTTPException( + status_code=422, detail="percent, delta_percent or steps required" + ) return emit( SetVolumeRequested( volume=to_device_volume(max(0, min(100, target)), general), source="web" diff --git a/python-backend/musicmouse/services/web/schemas.py b/python-backend/musicmouse/services/web/schemas.py index ff5fcfd..d45d311 100644 --- a/python-backend/musicmouse/services/web/schemas.py +++ b/python-backend/musicmouse/services/web/schemas.py @@ -229,10 +229,16 @@ class SeekIn(BaseModel): class VolumeIn(BaseModel): - """Either an absolute percentage or a relative step; exactly one of the two.""" + """An absolute percentage, a relative percentage, or a number of steps; exactly + one of the three. + + ``steps`` moves by ``volume_increment`` device units per step - the same step the + IR remote's volume keys and the rotary encoder take - rather than by a percentage. + """ percent: int | None = Field(default=None, ge=0, le=100) delta_percent: int | None = Field(default=None, ge=-100, le=100) + steps: int | None = Field(default=None, ge=-10, le=10) class SettingsOut(BaseModel): diff --git a/python-backend/tests/test_web.py b/python-backend/tests/test_web.py index 85e8930..1b9ec0b 100644 --- a/python-backend/tests/test_web.py +++ b/python-backend/tests/test_web.py @@ -292,13 +292,29 @@ async def test_volume_steps_clamp_at_the_ends(client: Client, sim: Simulation) - assert (await client.get("/api/state")).json()["volume"] == 100 +async def test_remote_steps_move_by_the_volume_increment(client: Client, sim: Simulation) -> None: + """The +/- buttons take the IR remote's step, in device units, not a percentage.""" + await client.post("/api/volume", json={"percent": 50}) + await sim.bus.drain() + before = sim.player.volume + step = sim.app.config.general.volume_increment + + assert (await client.post("/api/volume", json={"steps": 1})).status_code == 204 + await sim.bus.drain() + assert sim.player.volume == before + step + + await client.post("/api/volume", json={"steps": -2}) + await sim.bus.drain() + assert sim.player.volume == before - step + + async def test_the_device_volume_range_is_never_exposed(client: Client) -> None: state = (await client.get("/api/state")).json() assert "volume_min" not in state assert "volume_max" not in state -async def test_volume_needs_one_of_the_two_fields(client: Client) -> None: +async def test_volume_needs_one_of_the_fields(client: Client) -> None: assert (await client.post("/api/volume", json={})).status_code == 422 diff --git a/web/src/App.tsx b/web/src/App.tsx index c5c1983..460ef34 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -185,6 +185,12 @@ export function App() { [connection], ); + /** The IR remote's own volume step (the backend's `volume_increment`), not a + * percentage - so no optimistic patch: the state push brings the new level. */ + const stepVolume = useCallback((steps: number) => { + void api.stepVolume(steps); + }, []); + const toggle = useCallback(() => { if (!state) return; if (state.album_id === null) { @@ -576,6 +582,7 @@ export function App() { onPrevious={onPrevious} onSeek={seekTo} onVolume={setVolume} + onVolumeStep={stepVolume} onMute={onMute} onOpenAlbum={onOpenCurrentAlbum} onOpenAssign={() => setAssignPopupOpen(true)} @@ -685,6 +692,7 @@ export function App() { onPrevious={onPrevious} onSeek={seekTo} onVolume={setVolume} + onVolumeStep={stepVolume} onMute={onMute} onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))} /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7d091e2..0f920ba 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -97,6 +97,8 @@ export const api = { seek: (position: number) => post("/seek", { position }), setVolume: (percent: number) => post("/volume", { percent }), nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }), + /** One step of the IR remote's volume keys per unit, in the device's own increment. */ + stepVolume: (steps: number) => post("/volume", { steps }), toggleScreen: () => post("/screen/toggle"), /** Someone is at the kiosk: holds off the screen's idle auto-off. */ screenActivity: () => post("/screen/activity"), diff --git a/web/src/components/PlayView.tsx b/web/src/components/PlayView.tsx index 207276a..71e66e1 100644 --- a/web/src/components/PlayView.tsx +++ b/web/src/components/PlayView.tsx @@ -20,6 +20,8 @@ interface Props { onPrevious: () => void; onSeek: (position: number) => void; onVolume: (percent: number) => void; + /** One IR-remote-sized step up (+1) or down (-1). */ + onVolumeStep: (steps: number) => void; onMute: () => void; onOpenAlbum: () => void; /** Assigning the current album/show to a remote key. `assignPending` is true right @@ -42,6 +44,7 @@ export function PlayView({ onPrevious, onSeek, onVolume, + onVolumeStep, onMute, onOpenAlbum, onOpenAssign, @@ -221,6 +224,7 @@ export function PlayView({ void; onSeek: (position: number) => void; onVolume: (percent: number) => void; + /** One IR-remote-sized step up (+1) or down (-1). */ + onVolumeStep: (steps: number) => void; onMute: () => void; onOpenPlayView: () => void; } @@ -30,6 +32,7 @@ export function PlayerBar({ onPrevious, onSeek, onVolume, + onVolumeStep, onMute, onOpenPlayView, }: Props) { @@ -153,6 +156,7 @@ export function PlayerBar({ void; + /** +1 or -1: one press of the IR remote's volume key. */ + onStep: (steps: number) => void; onMute: () => void; barWidth: number; barHeight: string; @@ -12,9 +15,51 @@ interface Props { iconSize: number; } +function StepButton({ + label, + symbol, + size, + onClick, +}: { + label: string; + symbol: string; + size: number; + onClick: () => void; +}) { + return ( + + ); +} + export function VolumeBars({ volume, onChange, + onStep, onMute, barWidth, barHeight, @@ -37,6 +82,7 @@ export function VolumeBars({ > {volume === 0 ? "🔇" : "🔊"} + onStep(-1)} />
{[20, 40, 60, 80, 100].map((level) => (
+ onStep(1)} /> ); }