Volume: quieter/louder buttons that take the remote's own step

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:44:07 +02:00
parent c74cb09324
commit 1254790374
9 changed files with 101 additions and 6 deletions

View File

@@ -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. | | `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/play` | `{album_id, track_index?}` |
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport | | `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 | | `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 | | `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 | | `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off |

View File

@@ -33,6 +33,7 @@ from musicmouse.events import (
SetVolumeRequested, SetVolumeRequested,
ToggleScreenRequested, ToggleScreenRequested,
UserActivity, UserActivity,
VolumeChangeRequested,
) )
from musicmouse.services.web.hub import StateHub from musicmouse.services.web.hub import StateHub
from musicmouse.services.web.remote_settings import read_mapping, write_mapping from musicmouse.services.web.remote_settings import read_mapping, write_mapping
@@ -212,12 +213,19 @@ def build_router(
@router.post("/volume", status_code=204) @router.post("/volume", status_code=204)
def set_volume(body: VolumeIn) -> Response: def set_volume(body: VolumeIn) -> Response:
general = app.config.general 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: if body.percent is not None:
target = body.percent target = body.percent
elif body.delta_percent is not None: elif body.delta_percent is not None:
target = to_percent(app.player.volume, general) + body.delta_percent target = to_percent(app.player.volume, general) + body.delta_percent
else: 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( return emit(
SetVolumeRequested( SetVolumeRequested(
volume=to_device_volume(max(0, min(100, target)), general), source="web" volume=to_device_volume(max(0, min(100, target)), general), source="web"

View File

@@ -229,10 +229,16 @@ class SeekIn(BaseModel):
class VolumeIn(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) percent: int | None = Field(default=None, ge=0, le=100)
delta_percent: int | None = Field(default=None, ge=-100, 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): class SettingsOut(BaseModel):

View File

@@ -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 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: async def test_the_device_volume_range_is_never_exposed(client: Client) -> None:
state = (await client.get("/api/state")).json() state = (await client.get("/api/state")).json()
assert "volume_min" not in state assert "volume_min" not in state
assert "volume_max" 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 assert (await client.post("/api/volume", json={})).status_code == 422

View File

@@ -185,6 +185,12 @@ export function App() {
[connection], [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(() => { const toggle = useCallback(() => {
if (!state) return; if (!state) return;
if (state.album_id === null) { if (state.album_id === null) {
@@ -576,6 +582,7 @@ export function App() {
onPrevious={onPrevious} onPrevious={onPrevious}
onSeek={seekTo} onSeek={seekTo}
onVolume={setVolume} onVolume={setVolume}
onVolumeStep={stepVolume}
onMute={onMute} onMute={onMute}
onOpenAlbum={onOpenCurrentAlbum} onOpenAlbum={onOpenCurrentAlbum}
onOpenAssign={() => setAssignPopupOpen(true)} onOpenAssign={() => setAssignPopupOpen(true)}
@@ -685,6 +692,7 @@ export function App() {
onPrevious={onPrevious} onPrevious={onPrevious}
onSeek={seekTo} onSeek={seekTo}
onVolume={setVolume} onVolume={setVolume}
onVolumeStep={stepVolume}
onMute={onMute} onMute={onMute}
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))} onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
/> />

View File

@@ -97,6 +97,8 @@ export const api = {
seek: (position: number) => post("/seek", { position }), seek: (position: number) => post("/seek", { position }),
setVolume: (percent: number) => post("/volume", { percent }), setVolume: (percent: number) => post("/volume", { percent }),
nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }), 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"), toggleScreen: () => post("/screen/toggle"),
/** Someone is at the kiosk: holds off the screen's idle auto-off. */ /** Someone is at the kiosk: holds off the screen's idle auto-off. */
screenActivity: () => post("/screen/activity"), screenActivity: () => post("/screen/activity"),

View File

@@ -20,6 +20,8 @@ interface Props {
onPrevious: () => void; onPrevious: () => void;
onSeek: (position: number) => void; onSeek: (position: number) => void;
onVolume: (percent: number) => void; onVolume: (percent: number) => void;
/** One IR-remote-sized step up (+1) or down (-1). */
onVolumeStep: (steps: number) => void;
onMute: () => void; onMute: () => void;
onOpenAlbum: () => void; onOpenAlbum: () => void;
/** Assigning the current album/show to a remote key. `assignPending` is true right /** Assigning the current album/show to a remote key. `assignPending` is true right
@@ -42,6 +44,7 @@ export function PlayView({
onPrevious, onPrevious,
onSeek, onSeek,
onVolume, onVolume,
onVolumeStep,
onMute, onMute,
onOpenAlbum, onOpenAlbum,
onOpenAssign, onOpenAssign,
@@ -221,6 +224,7 @@ export function PlayView({
<VolumeBars <VolumeBars
volume={state.volume} volume={state.volume}
onChange={onVolume} onChange={onVolume}
onStep={onVolumeStep}
onMute={onMute} onMute={onMute}
barWidth={18} barWidth={18}
barHeight="clamp(26px, 4.6vh, 44px)" barHeight="clamp(26px, 4.6vh, 44px)"

View File

@@ -18,6 +18,8 @@ interface Props {
onPrevious: () => void; onPrevious: () => void;
onSeek: (position: number) => void; onSeek: (position: number) => void;
onVolume: (percent: number) => void; onVolume: (percent: number) => void;
/** One IR-remote-sized step up (+1) or down (-1). */
onVolumeStep: (steps: number) => void;
onMute: () => void; onMute: () => void;
onOpenPlayView: () => void; onOpenPlayView: () => void;
} }
@@ -30,6 +32,7 @@ export function PlayerBar({
onPrevious, onPrevious,
onSeek, onSeek,
onVolume, onVolume,
onVolumeStep,
onMute, onMute,
onOpenPlayView, onOpenPlayView,
}: Props) { }: Props) {
@@ -153,6 +156,7 @@ export function PlayerBar({
<VolumeBars <VolumeBars
volume={state.volume} volume={state.volume}
onChange={onVolume} onChange={onVolume}
onStep={onVolumeStep}
onMute={onMute} onMute={onMute}
barWidth={11} barWidth={11}
barHeight="30px" barHeight="30px"

View File

@@ -1,10 +1,13 @@
/** Five bars, not a slider. The device's real range never reaches the browser: 100 % /** Five bars, not a slider, flanked by a quieter and a louder button. The device's real
* here means whatever ceiling a parent configured. */ * range never reaches the browser: 100 % here means whatever ceiling a parent
* configured. The bars jump to a level; the buttons take the IR remote's own step. */
interface Props { interface Props {
/** 0..100. */ /** 0..100. */
volume: number; volume: number;
onChange: (percent: number) => void; onChange: (percent: number) => void;
/** +1 or -1: one press of the IR remote's volume key. */
onStep: (steps: number) => void;
onMute: () => void; onMute: () => void;
barWidth: number; barWidth: number;
barHeight: string; barHeight: string;
@@ -12,9 +15,51 @@ interface Props {
iconSize: number; iconSize: number;
} }
function StepButton({
label,
symbol,
size,
onClick,
}: {
label: string;
symbol: string;
size: number;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
title={label}
aria-label={label}
style={{
border: "none",
padding: 0,
cursor: "pointer",
width: size * 1.4,
height: size * 1.4,
borderRadius: 999,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
// The active bars' colour, so the pair reads on the light play view and the
// dark player bar alike.
background: "var(--accent)",
color: "#fff",
fontSize: size,
fontWeight: 900,
lineHeight: 1,
}}
>
{symbol}
</button>
);
}
export function VolumeBars({ export function VolumeBars({
volume, volume,
onChange, onChange,
onStep,
onMute, onMute,
barWidth, barWidth,
barHeight, barHeight,
@@ -37,6 +82,7 @@ export function VolumeBars({
> >
{volume === 0 ? "🔇" : "🔊"} {volume === 0 ? "🔇" : "🔊"}
</button> </button>
<StepButton label="Leiser" symbol="−" size={iconSize} onClick={() => onStep(-1)} />
<div style={{ display: "flex", gap }}> <div style={{ display: "flex", gap }}>
{[20, 40, 60, 80, 100].map((level) => ( {[20, 40, 60, 80, 100].map((level) => (
<button <button
@@ -57,6 +103,7 @@ export function VolumeBars({
/> />
))} ))}
</div> </div>
<StepButton label="Lauter" symbol="+" size={iconSize} onClick={() => onStep(1)} />
</div> </div>
); );
} }