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:
@@ -374,7 +374,15 @@ def _build_services(
|
|||||||
|
|
||||||
screen_config = app.config.general.screen
|
screen_config = app.config.general.screen
|
||||||
if screen_config is not None:
|
if screen_config is not None:
|
||||||
services.append(ScreenService(app.bus, screen_config))
|
general = app.config.general
|
||||||
|
services.append(
|
||||||
|
ScreenService(
|
||||||
|
app.bus,
|
||||||
|
screen_config,
|
||||||
|
alsa_device=None if general.audio_simulated else general.alsa_device,
|
||||||
|
volume=lambda: app.player.volume / 100,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
||||||
# into its folder, so there is nothing to gate here with its own config section.
|
# into its folder, so there is nothing to gate here with its own config section.
|
||||||
|
|||||||
62
python-backend/musicmouse/blubb.py
Normal file
62
python-backend/musicmouse/blubb.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""A synthesised "blubb": two bubbles, for the screen switching on or off.
|
||||||
|
|
||||||
|
Built from samples rather than shipped as a file - the same idea as the front-end's
|
||||||
|
WebAudio blips (``web/src/lib/tippen/pop.ts``), and it lets the loudness follow the
|
||||||
|
player's volume instead of being fixed in a recording.
|
||||||
|
|
||||||
|
A bubble is a sine whose pitch rises as it decays (an air bubble's resonance climbs as
|
||||||
|
it reaches the surface). Two of them make the "blubb"; switching on the second one is
|
||||||
|
higher, switching off it is lower, so the two directions are told apart by ear.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
import wave
|
||||||
|
from array import array
|
||||||
|
|
||||||
|
__all__ = ["RATE", "blubb_wav"]
|
||||||
|
|
||||||
|
RATE = 44100
|
||||||
|
|
||||||
|
#: (start Hz, end Hz, start s, length s) of each bubble.
|
||||||
|
_ON = ((260.0, 620.0, 0.0, 0.11), (380.0, 900.0, 0.13, 0.13))
|
||||||
|
_OFF = ((380.0, 900.0, 0.0, 0.11), (240.0, 560.0, 0.13, 0.15))
|
||||||
|
|
||||||
|
|
||||||
|
def blubb_wav(*, on: bool, gain: float) -> bytes:
|
||||||
|
"""A mono 16-bit WAV. ``gain`` is the peak, 0..1 of full scale."""
|
||||||
|
bubbles = _ON if on else _OFF
|
||||||
|
length = max(start + duration for _, _, start, duration in bubbles)
|
||||||
|
samples = [0.0] * (int(length * RATE) + 1)
|
||||||
|
for start_hz, end_hz, start, duration in bubbles:
|
||||||
|
_add_bubble(samples, start_hz, end_hz, int(start * RATE), duration)
|
||||||
|
|
||||||
|
peak = max(abs(s) for s in samples) or 1.0
|
||||||
|
scale = 32767 * max(0.0, min(1.0, gain)) / peak
|
||||||
|
pcm = array("h", (round(s * scale) for s in samples))
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with wave.open(buffer, "wb") as out:
|
||||||
|
out.setnchannels(1)
|
||||||
|
out.setsampwidth(2)
|
||||||
|
out.setframerate(RATE)
|
||||||
|
out.writeframes(pcm.tobytes())
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_bubble(
|
||||||
|
samples: list[float], start_hz: float, end_hz: float, offset: int, duration: float
|
||||||
|
) -> None:
|
||||||
|
count = int(duration * RATE)
|
||||||
|
attack = int(0.004 * RATE) # a few ms of fade-in, or the first sample clicks
|
||||||
|
phase = 0.0
|
||||||
|
for i in range(count):
|
||||||
|
t = i / count
|
||||||
|
# Exponential sweep, fast at first like the real thing.
|
||||||
|
frequency = start_hz * (end_hz / start_hz) ** (1 - (1 - t) ** 2)
|
||||||
|
phase += 2 * math.pi * frequency / RATE
|
||||||
|
envelope = min(1.0, i / attack) * math.exp(-5 * t)
|
||||||
|
index = offset + i
|
||||||
|
if index < len(samples):
|
||||||
|
samples[index] += math.sin(phase) * envelope
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
A service rather than a reaction because the command is a subprocess: the bus awaits
|
A service rather than a reaction because the command is a subprocess: the bus awaits
|
||||||
async handlers, so running it there would hold every other event up behind ``xset``.
|
async handlers, so running it there would hold every other event up behind ``xset``.
|
||||||
Here each press gets its own task, and a hung or failing command is only logged.
|
Here each press gets its own task, and a hung or failing command is only logged.
|
||||||
|
|
||||||
|
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
|
||||||
|
ALSA device, at the player's volume. The command's last word of output says which way
|
||||||
|
it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -10,7 +14,9 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from musicmouse.blubb import blubb_wav
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
from musicmouse.config import ScreenConfig
|
from musicmouse.config import ScreenConfig
|
||||||
from musicmouse.events import ToggleScreenRequested
|
from musicmouse.events import ToggleScreenRequested
|
||||||
@@ -19,13 +25,28 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = ["ScreenService"]
|
__all__ = ["ScreenService"]
|
||||||
|
|
||||||
|
#: The blubb's peak at full player volume, as a fraction of full scale. The music is
|
||||||
|
#: mastered near full scale; a notification sound sits well under it.
|
||||||
|
_BLUBB_GAIN = 0.35
|
||||||
|
|
||||||
|
|
||||||
class ScreenService:
|
class ScreenService:
|
||||||
name = "screen"
|
name = "screen"
|
||||||
|
|
||||||
def __init__(self, bus: EventBus, config: ScreenConfig) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
bus: EventBus,
|
||||||
|
config: ScreenConfig,
|
||||||
|
*,
|
||||||
|
alsa_device: str | None,
|
||||||
|
volume: Callable[[], float],
|
||||||
|
) -> None:
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.config = config
|
self.config = config
|
||||||
|
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
|
||||||
|
self.alsa_device = alsa_device
|
||||||
|
#: The player's volume as a fraction, read at the moment the sound plays.
|
||||||
|
self.volume = volume
|
||||||
self._running: asyncio.Task[None] | None = None
|
self._running: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -73,5 +94,41 @@ class ScreenService:
|
|||||||
process.returncode,
|
process.returncode,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
_log.info("Screen toggled%s", f": {text}" if text else "")
|
_log.info("Screen toggled%s", f": {text}" if text else "")
|
||||||
|
state = text.split()[-1] if text else ""
|
||||||
|
if state in ("on", "off"):
|
||||||
|
await self.blubb(on=state == "on")
|
||||||
|
|
||||||
|
async def blubb(self, *, on: bool) -> None:
|
||||||
|
gain = _BLUBB_GAIN * max(0.0, min(1.0, self.volume()))
|
||||||
|
if self.alsa_device is None or gain <= 0:
|
||||||
|
return
|
||||||
|
wav = await asyncio.to_thread(blubb_wav, on=on, gain=gain)
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"aplay",
|
||||||
|
"-q",
|
||||||
|
"-D",
|
||||||
|
self.alsa_device,
|
||||||
|
"-",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
_log.warning("Could not play the screen sound: %s", exc)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_, error = await asyncio.wait_for(process.communicate(wav), self.config.timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
_log.warning("Screen sound took longer than %gs; killed", self.config.timeout)
|
||||||
|
return
|
||||||
|
if process.returncode != 0:
|
||||||
|
_log.warning(
|
||||||
|
"Screen sound failed (exit %d): %s",
|
||||||
|
process.returncode,
|
||||||
|
error.decode(errors="replace").strip(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from musicmouse.events import (
|
|||||||
PreviousTrackRequested,
|
PreviousTrackRequested,
|
||||||
SeekRequested,
|
SeekRequested,
|
||||||
SetVolumeRequested,
|
SetVolumeRequested,
|
||||||
|
ToggleScreenRequested,
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -189,6 +190,13 @@ def build_router(
|
|||||||
def previous_track() -> Response:
|
def previous_track() -> Response:
|
||||||
return emit(PreviousTrackRequested(source="web"))
|
return emit(PreviousTrackRequested(source="web"))
|
||||||
|
|
||||||
|
@router.post("/screen/toggle", status_code=204)
|
||||||
|
def toggle_screen() -> Response:
|
||||||
|
# The device's own monitor - only the kiosk page on the device calls this (its
|
||||||
|
# Del key). Without a screen section in the config nothing listens and it is a
|
||||||
|
# no-op, like the remote's power key.
|
||||||
|
return emit(ToggleScreenRequested(source="web"))
|
||||||
|
|
||||||
@router.post("/seek", status_code=204)
|
@router.post("/seek", status_code=204)
|
||||||
def seek(body: SeekIn) -> Response:
|
def seek(body: SeekIn) -> Response:
|
||||||
return emit(SeekRequested(position=body.position, source="web"))
|
return emit(SeekRequested(position=body.position, source="web"))
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import io
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
|
import wave
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.blubb import RATE, blubb_wav
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
from musicmouse.config import ScreenConfig
|
from musicmouse.config import ScreenConfig
|
||||||
from musicmouse.events import ToggleScreenRequested
|
from musicmouse.events import ToggleScreenRequested
|
||||||
@@ -36,8 +40,14 @@ async def _settle(service: ScreenService) -> None:
|
|||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
async def _started(bus: EventBus, config: ScreenConfig) -> tuple[ScreenService, asyncio.Task[None]]:
|
async def _started(
|
||||||
service = ScreenService(bus, config)
|
bus: EventBus,
|
||||||
|
config: ScreenConfig,
|
||||||
|
*,
|
||||||
|
alsa_device: str | None = None,
|
||||||
|
volume: float = 0.5,
|
||||||
|
) -> tuple[ScreenService, asyncio.Task[None]]:
|
||||||
|
service = ScreenService(bus, config, alsa_device=alsa_device, volume=lambda: volume)
|
||||||
task = asyncio.create_task(service.run())
|
task = asyncio.create_task(service.run())
|
||||||
await asyncio.sleep(0) # let it subscribe
|
await asyncio.sleep(0) # let it subscribe
|
||||||
return service, task
|
return service, task
|
||||||
@@ -108,3 +118,69 @@ async def test_a_hung_command_is_killed(bus: EventBus, caplog: pytest.LogCapture
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
assert "killed" in caplog.text
|
assert "killed" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ the blubb
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_blubb_is_a_short_mono_wav() -> None:
|
||||||
|
with wave.open(io.BytesIO(blubb_wav(on=True, gain=0.5))) as wav:
|
||||||
|
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
|
||||||
|
assert 0.1 < wav.getnframes() / RATE < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_and_off_sound_different() -> None:
|
||||||
|
assert blubb_wav(on=True, gain=0.5) != blubb_wav(on=False, gain=0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_aplay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
"""An ``aplay`` on PATH that records its arguments and what it was fed."""
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
record = tmp_path / "aplay"
|
||||||
|
script = bin_dir / "aplay"
|
||||||
|
script.write_text(
|
||||||
|
f'#!/bin/sh\necho "$@" > {shlex.quote(str(record))}.args\n'
|
||||||
|
f"cat > {shlex.quote(str(record))}.wav\n"
|
||||||
|
)
|
||||||
|
script.chmod(0o755)
|
||||||
|
monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}")
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_switch_plays_the_blubb_its_way(bus: EventBus, fake_aplay: Path) -> None:
|
||||||
|
service, task = await _started(
|
||||||
|
bus, ScreenConfig(toggle_command=_python("print('off')")), alsa_device="softvol_effects"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(service)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert Path(f"{fake_aplay}.args").read_text().split() == ["-q", "-D", "softvol_effects", "-"]
|
||||||
|
assert Path(f"{fake_aplay}.wav").read_bytes() == blubb_wav(on=False, gain=0.35 * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("output", "alsa_device", "volume"),
|
||||||
|
[
|
||||||
|
("print('on')", None, 0.5), # simulated audio
|
||||||
|
("print('on')", "softvol_effects", 0.0), # muted
|
||||||
|
("print('done')", "softvol_effects", 0.5), # no direction to go by
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_no_blubb(
|
||||||
|
bus: EventBus, fake_aplay: Path, output: str, alsa_device: str | None, volume: float
|
||||||
|
) -> None:
|
||||||
|
service, task = await _started(
|
||||||
|
bus, ScreenConfig(toggle_command=_python(output)), alsa_device=alsa_device, volume=volume
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(service)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert not Path(f"{fake_aplay}.wav").exists()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import pytest
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from musicmouse.config import WebConfig, load_config
|
from musicmouse.config import WebConfig, load_config
|
||||||
|
from musicmouse.events import ToggleScreenRequested
|
||||||
from musicmouse.library.analysis import BeatGrid, TrackCurves
|
from musicmouse.library.analysis import BeatGrid, TrackCurves
|
||||||
from musicmouse.library.models import track_key
|
from musicmouse.library.models import track_key
|
||||||
from musicmouse.services.web.service import build_app
|
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
|
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:
|
async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None:
|
||||||
album = await album_by_title(client, "Fuchs")
|
album = await album_by_title(client, "Fuchs")
|
||||||
await client.post("/api/play", json={"album_id": album["id"]})
|
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
|
# ------------------------------------------------------------------------- volume
|
||||||
|
|
||||||
|
|
||||||
async def test_full_volume_means_the_configured_ceiling(
|
async def test_full_volume_means_the_configured_ceiling(client: Client, sim: Simulation) -> None:
|
||||||
client: Client, sim: Simulation
|
|
||||||
) -> None:
|
|
||||||
"""The child sees 0..100; the config's max of 60 never crosses the API boundary."""
|
"""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})
|
response = await client.post("/api/volume", json={"percent": 100})
|
||||||
assert response.status_code == 204
|
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
|
assert (await client.get("/api/state")).json()["volume"] == 100
|
||||||
|
|
||||||
|
|
||||||
async def test_volume_scales_across_the_allowed_range(
|
async def test_volume_scales_across_the_allowed_range(client: Client, sim: Simulation) -> None:
|
||||||
client: Client, sim: Simulation
|
|
||||||
) -> None:
|
|
||||||
await client.post("/api/volume", json={"percent": 50})
|
await client.post("/api/volume", json={"percent": 50})
|
||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
assert sim.player.volume == 30
|
assert sim.player.volume == 30
|
||||||
assert (await client.get("/api/state")).json()["volume"] == 50
|
assert (await client.get("/api/state")).json()["volume"] == 50
|
||||||
|
|
||||||
|
|
||||||
async def test_volume_steps_are_relative_to_the_percentage(
|
async def test_volume_steps_are_relative_to_the_percentage(client: Client, sim: Simulation) -> None:
|
||||||
client: Client, sim: Simulation
|
|
||||||
) -> None:
|
|
||||||
await client.post("/api/volume", json={"percent": 50})
|
await client.post("/api/volume", json={"percent": 50})
|
||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
await client.post("/api/volume", json={"delta_percent": 10})
|
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 def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None:
|
||||||
async with websocket_connect(api, "/api/ws") as first, websocket_connect(
|
async with (
|
||||||
api, "/api/ws"
|
websocket_connect(api, "/api/ws") as first,
|
||||||
) as second:
|
websocket_connect(api, "/api/ws") as second,
|
||||||
|
):
|
||||||
await first.next_json()
|
await first.next_json()
|
||||||
await second.next_json()
|
await second.next_json()
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ const ASSIGN_STATUS_TIMEOUT_MS = 2500;
|
|||||||
/** Volume when un-muting, matching the mockup. */
|
/** Volume when un-muting, matching the mockup. */
|
||||||
const UNMUTE_PERCENT = 60;
|
const UNMUTE_PERCENT = 60;
|
||||||
|
|
||||||
|
/** The kiosk on the device loads the page from localhost (`pi_kiosk_url` in the ansible
|
||||||
|
* repo); anything else - a tablet, a laptop - is looking at it from elsewhere, and Del
|
||||||
|
* there must not switch off a screen in another room. */
|
||||||
|
const ON_THE_DEVICE = ["localhost", "127.0.0.1"].includes(window.location.hostname);
|
||||||
|
|
||||||
/** A podcast episode has no next/previous track to skip to - Next/Previous nudge the
|
/** A podcast episode has no next/previous track to skip to - Next/Previous nudge the
|
||||||
* position instead, the way scrubbing past an ad break usually works. */
|
* position instead, the way scrubbing past an ad break usually works. */
|
||||||
const PODCAST_SKIP_SECONDS = 30;
|
const PODCAST_SKIP_SECONDS = 30;
|
||||||
@@ -313,6 +318,9 @@ export function App() {
|
|||||||
case "mute":
|
case "mute":
|
||||||
onMute();
|
onMute();
|
||||||
break;
|
break;
|
||||||
|
case "screen":
|
||||||
|
if (ON_THE_DEVICE) void api.toggleScreen();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ 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 }),
|
||||||
|
toggleScreen: () => post("/screen/toggle"),
|
||||||
|
|
||||||
settings: () => request<Settings>("/settings"),
|
settings: () => request<Settings>("/settings"),
|
||||||
saveSettings: (settings: Settings) =>
|
saveSettings: (settings: Settings) =>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string
|
|||||||
"Sonstiges",
|
"Sonstiges",
|
||||||
[
|
[
|
||||||
[["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"],
|
[["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"],
|
||||||
|
[["ENTF"], "Bildschirm aus / an"],
|
||||||
[["F1"], "Diese Hilfe"],
|
[["F1"], "Diese Hilfe"],
|
||||||
[["ESC"], "Schließen / Suche löschen"],
|
[["ESC"], "Schließen / Suche löschen"],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -484,3 +484,11 @@ describe("selectionAt", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Del", () => {
|
||||||
|
it("toggles the screen, on every page", () => {
|
||||||
|
for (const page of ["music", "room", "typing"] as const) {
|
||||||
|
expect(press("Delete", { ...initialUiState, page })).toEqual([{ type: "screen" }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ export type Action =
|
|||||||
| { type: "seek"; delta: number }
|
| { type: "seek"; delta: number }
|
||||||
| { type: "pop"; freq: number }
|
| { type: "pop"; freq: number }
|
||||||
| { type: "assign"; digit: string }
|
| { type: "assign"; digit: string }
|
||||||
| { type: "mute" };
|
| { type: "mute" }
|
||||||
|
| { type: "screen" };
|
||||||
|
|
||||||
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
||||||
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
||||||
@@ -253,6 +254,12 @@ export function handleKey(
|
|||||||
): Action[] {
|
): Action[] {
|
||||||
const { key } = event;
|
const { key } = event;
|
||||||
|
|
||||||
|
// Switches the device's own monitor on and off, like the IR remote's power key -
|
||||||
|
// from every page, the typing tab included (no lesson drills Del). App.tsx only acts
|
||||||
|
// on it in the kiosk on the device itself; on a tablet it would switch a screen in
|
||||||
|
// another room.
|
||||||
|
if (key === "Delete" && !event.ctrlKey && !event.metaKey) return [{ type: "screen" }];
|
||||||
|
|
||||||
// The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its
|
// The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its
|
||||||
// own complete keyboard handling, including its own Escape/F1 bindings. It must own
|
// own complete keyboard handling, including its own Escape/F1 bindings. It must own
|
||||||
// every key while active - even Shift+media and Space below, which would otherwise
|
// every key while active - even Shift+media and Space below, which would otherwise
|
||||||
|
|||||||
Reference in New Issue
Block a user