Screen switch: a blubb on every switch, and Del on the kiosk keyboard

ScreenService plays a synthesised two-bubble "blubb" (musicmouse.blubb) after each
switch, through aplay on the music's ALSA device at the player's volume: rising for
on, falling for off, going by the last word the toggle command prints. Nothing plays
with simulated audio or at volume 0. Built from samples rather than shipped as a
file, like the front-end's WebAudio blips, so its loudness follows the volume.

Del maps to a new "screen" action on every page, which App.tsx sends to the new
POST /api/screen/toggle only when the page is served from localhost - the kiosk on
the device - so Del on a tablet does not switch off a screen in another room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:00:37 +02:00
parent 2d0caeb732
commit 92305bece0
11 changed files with 262 additions and 19 deletions

View File

@@ -18,6 +18,7 @@ import pytest
from fastapi import FastAPI
from musicmouse.config import WebConfig, load_config
from musicmouse.events import ToggleScreenRequested
from musicmouse.library.analysis import BeatGrid, TrackCurves
from musicmouse.library.models import track_key
from musicmouse.services.web.service import build_app
@@ -227,6 +228,17 @@ async def test_transport_commands_reach_the_player(client: Client, sim: Simulati
assert sim.player.is_playing
async def test_the_screen_toggle_is_an_intent(client: Client, sim: Simulation) -> None:
seen: list[ToggleScreenRequested] = []
sim.bus.subscribe(ToggleScreenRequested, seen.append)
response = await client.post("/api/screen/toggle")
await sim.bus.drain()
assert response.status_code == 204
assert [event.source for event in seen] == ["web"]
async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None:
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"]})
@@ -245,9 +257,7 @@ async def test_seeking_backwards_is_rejected(client: Client) -> None:
# ------------------------------------------------------------------------- volume
async def test_full_volume_means_the_configured_ceiling(
client: Client, sim: Simulation
) -> None:
async def test_full_volume_means_the_configured_ceiling(client: Client, sim: Simulation) -> None:
"""The child sees 0..100; the config's max of 60 never crosses the API boundary."""
response = await client.post("/api/volume", json={"percent": 100})
assert response.status_code == 204
@@ -257,18 +267,14 @@ async def test_full_volume_means_the_configured_ceiling(
assert (await client.get("/api/state")).json()["volume"] == 100
async def test_volume_scales_across_the_allowed_range(
client: Client, sim: Simulation
) -> None:
async def test_volume_scales_across_the_allowed_range(client: Client, sim: Simulation) -> None:
await client.post("/api/volume", json={"percent": 50})
await sim.bus.drain()
assert sim.player.volume == 30
assert (await client.get("/api/state")).json()["volume"] == 50
async def test_volume_steps_are_relative_to_the_percentage(
client: Client, sim: Simulation
) -> None:
async def test_volume_steps_are_relative_to_the_percentage(client: Client, sim: Simulation) -> None:
await client.post("/api/volume", json={"percent": 50})
await sim.bus.drain()
await client.post("/api/volume", json={"delta_percent": 10})
@@ -325,9 +331,10 @@ async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) -
async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None:
async with websocket_connect(api, "/api/ws") as first, websocket_connect(
api, "/api/ws"
) as second:
async with (
websocket_connect(api, "/api/ws") as first,
websocket_connect(api, "/api/ws") as second,
):
await first.next_json()
await second.next_json()