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:
@@ -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 |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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" }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user