Add IR remote control (LIRC) with a number-key content mapping

Adds a TCP client for lircd's classic protocol: play/pause/next/prev/
volume/mute map to the same intents every other front-end already
emits, and number keys 0-9 play an assigned album/audiobook from the
start or a podcast show's newest episode, resolved fresh on every
press. The mapping is configured in config.yml and editable from the
frontend: a small "Taste zuweisen" button on the play screen (or the
A+digit keyboard shortcut) opens a 10-key picker to assign whatever is
currently playing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 08:31:28 +02:00
parent 57afc32f4a
commit 747c390303
32 changed files with 1603 additions and 33 deletions

View File

@@ -66,6 +66,18 @@ general:
# Built frontend to serve at /. Omit to expose only the JSON API. # Built frontend to serve at /. Omit to expose only the JSON API.
static_dir: ../web/dist static_dir: ../web/dist
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
# lircd itself is set up on the Pi). Omit the whole section to run without a remote.
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
# number keys 0-9 play whatever the "remote:" section below assigns them.
lirc:
host: "musicmouse-pi.local"
port: 2222 # this deployment's lircd listens on 2222, not its own
# default of 8765 - see the ansible role
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
# remote, say) are ignored
reconnect_interval: 5.0
# Home Assistant integration. Omit the whole section to run without MQTT. # Home Assistant integration. Omit the whole section to run without MQTT.
# The backend exposes three lights, a player sensor, a volume slider, transport # The backend exposes three lights, a player sensor, a volume slider, transport
# buttons, device triggers for every button/touch area, and a tag scanner. # buttons, device triggers for every button/touch area, and a tag scanner.
@@ -124,3 +136,20 @@ figures:
id: "04b2c3d4e5" id: "04b2c3d4e5"
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"] colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
kind: book kind: book
# Number keys 0-9 on the IR remote, mapped to what they play. Omit the whole section,
# or any digit within it, for "unassigned" - a fresh install boots with none of this
# and that is not an error. Editable from the web front-end, which writes back here.
#
# target_kind: album -> always starts from the first track (music, audiobooks).
# target is an album id, as shown at GET /api/library.
# target_kind: series -> always plays the newest episode of a podcast show, resolved
# fresh on every press - never a fixed episode. target is the
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
remote:
"1":
target_kind: album
target: "3f9a0c12ab44"
"2":
target_kind: series
target: "Wissen macht Ah"

View File

@@ -36,6 +36,7 @@ from musicmouse.library import MusicLibrary
from musicmouse.library.analysis import build_analyzer from musicmouse.library.analysis import build_analyzer
from musicmouse.reactions import register_all from musicmouse.reactions import register_all
from musicmouse.services.base import Service from musicmouse.services.base import Service
from musicmouse.services.lirc import LircService
from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.podcasts import PodcastFeedService from musicmouse.services.podcasts import PodcastFeedService
from musicmouse.services.web import WebService from musicmouse.services.web import WebService
@@ -330,6 +331,12 @@ def _build_services(
else: else:
services.append(WebService(app, web_config, config_path)) services.append(WebService(app, web_config, config_path))
lirc_config = app.config.general.lirc
if lirc_config is None:
_log.info("No lirc section in the config: the IR remote is off")
else:
services.append(LircService(app, lirc_config, clock=clock))
# 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.
web_service = next((s for s in services if isinstance(s, WebService)), None) web_service = next((s for s in services if isinstance(s, WebService)), None)

View File

@@ -31,6 +31,9 @@ class AppState:
#: readable off the transport; a broker's is not, so it is remembered here. #: readable off the transport; a broker's is not, so it is remembered here.
mqtt_connected: bool = False mqtt_connected: bool = False
#: Whether the lircd TCP link for the IR remote is currently reachable.
lirc_connected: bool = False
@dataclass @dataclass
class App: class App:

View File

@@ -33,18 +33,24 @@ __all__ = [
"SIMULATE", "SIMULATE",
"Config", "Config",
"ConfigError", "ConfigError",
"Digit",
"FigureColors", "FigureColors",
"FigureConfig", "FigureConfig",
"GeneralConfig", "GeneralConfig",
"HaConfig", "HaConfig",
"HaDeviceConfig", "HaDeviceConfig",
"LibraryConfig", "LibraryConfig",
"LircConfig",
"MqttConfig", "MqttConfig",
"RemoteSlotConfig",
"WebConfig", "WebConfig",
"format_validation_error", "format_validation_error",
"load_config", "load_config",
] ]
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
type Digit = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac") DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or #: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or
@@ -133,6 +139,22 @@ class MqttConfig(_Strict):
reconnect_interval: float = Field(default=10.0, gt=0) reconnect_interval: float = Field(default=10.0, gt=0)
class LircConfig(_Strict):
"""TCP client for lircd's classic network protocol - see ``ansible/roles/pi_lirc``.
Omit the whole section to run without an IR remote.
"""
host: str
#: This deployment's lircd listens on 2222 (see the ansible role); lircd's own
#: default is 8765, so this is worth overriding rather than assuming.
port: int = Field(default=2222, ge=1, le=65535)
#: Only button events from this remote are acted on - other remotes registered
#: with the same lircd (an LED remote, say) are ignored.
remote_name: str = "Hauppauge"
reconnect_interval: float = Field(default=5.0, gt=0)
class LibraryConfig(_Strict): class LibraryConfig(_Strict):
"""Where the music lives. """Where the music lives.
@@ -221,6 +243,7 @@ class GeneralConfig(_Strict):
mqtt: MqttConfig | None = None mqtt: MqttConfig | None = None
web: WebConfig | None = None web: WebConfig | None = None
ha: HaConfig | None = None ha: HaConfig | None = None
lirc: LircConfig | None = None
min_volume: int = Field(default=0, ge=0, le=200) min_volume: int = Field(default=0, ge=0, le=200)
max_volume: int = Field(default=100, ge=0, le=200) max_volume: int = Field(default=100, ge=0, le=200)
@@ -262,9 +285,26 @@ class FigureConfig(_Strict):
kind: Literal["music", "book"] = "music" kind: Literal["music", "book"] = "music"
class RemoteSlotConfig(_Strict):
"""What a number key on the IR remote plays.
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
``Album.series``); resolved to that show's newest episode fresh on every press,
since a podcast show is not itself one playable thing in this library - each
episode is its own album.
"""
target_kind: Literal["album", "series"]
target: str
class Config(_Strict): class Config(_Strict):
general: GeneralConfig general: GeneralConfig
figures: dict[str, FigureConfig] = Field(min_length=1) figures: dict[str, FigureConfig] = Field(min_length=1)
#: Number key (0-9) -> what it plays. Empty by default: a fresh install has no
#: assignments, and that is not an error.
remote: dict[Digit, RemoteSlotConfig] = Field(default_factory=dict)
@model_validator(mode="after") @model_validator(mode="after")
def _check_unique_tag_ids(self) -> Self: def _check_unique_tag_ids(self) -> Self:

View File

@@ -35,8 +35,10 @@ __all__ = [
"LedEffectRequested", "LedEffectRequested",
"NextTrackRequested", "NextTrackRequested",
"PauseRequested", "PauseRequested",
"PlayAlbumRequested",
"PlayFigureRequested", "PlayFigureRequested",
"PlayRequested", "PlayRequested",
"PlaySeriesLatestRequested",
"PlaybackChanged", "PlaybackChanged",
"PlaylistFinished", "PlaylistFinished",
"PrevTrackRequested", "PrevTrackRequested",
@@ -51,7 +53,7 @@ __all__ = [
"VolumeChanged", "VolumeChanged",
] ]
type EventSource = Literal["device", "player", "mqtt", "web", "simulator", "system"] type EventSource = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
@@ -186,6 +188,17 @@ class PlayAlbumRequested(IntentEvent):
track_index: int = 0 track_index: int = 0
@dataclass(frozen=True, slots=True, kw_only=True)
class PlaySeriesLatestRequested(IntentEvent):
"""Start the newest episode of a podcast show.
The IR remote's number-key mapping assigns a whole show rather than one fixed
episode, so this is resolved to an actual album fresh on every press.
"""
series: str
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class SeekRequested(IntentEvent): class SeekRequested(IntentEvent):
#: Seconds from the start of the current track. #: Seconds from the start of the current track.
@@ -257,5 +270,5 @@ class LedEffectChanged(StateEvent):
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class ConnectionChanged(StateEvent): class ConnectionChanged(StateEvent):
target: Literal["firmware", "mqtt"] target: Literal["firmware", "mqtt", "lirc"]
connected: bool connected: bool

View File

@@ -124,6 +124,26 @@ class MusicLibrary:
if album.figure is not None if album.figure is not None
} }
def latest_episode(self, series: str) -> Album | None:
"""The newest episode-unit album of a podcast show, by filename.
Episode files are named ``YYYYMMDD - Title``, so filename order is
chronological - the same fact ``Kinderpodcasts``' ``order="newest_first"``
already relies on at scan time. ``None`` if the show is unknown or empty.
"""
candidates = [
album
for album in self.albums
if album.series == series
and (section := SECTIONS.get(album.section)) is not None
and section.album_unit == "episode"
]
if not candidates:
return None
return max(
candidates, key=lambda album: album.tracks[0].path.name if album.tracks else ""
)
def beats(self, identifier: str, index: int) -> BeatGrid | None: def beats(self, identifier: str, index: int) -> BeatGrid | None:
album = self.get(identifier) album = self.get(identifier)
if album is None or not 0 <= index < len(album.tracks): if album is None or not 0 <= index < len(album.tracks):

View File

@@ -19,6 +19,7 @@ from musicmouse.events import (
PlayFigureRequested, PlayFigureRequested,
PlaylistFinished, PlaylistFinished,
PlayRequested, PlayRequested,
PlaySeriesLatestRequested,
PrevTrackRequested, PrevTrackRequested,
RotaryTurned, RotaryTurned,
SeekRequested, SeekRequested,
@@ -26,6 +27,7 @@ from musicmouse.events import (
VolumeChangeRequested, VolumeChangeRequested,
) )
from musicmouse.hardware import Button, ButtonAction, RotaryDirection from musicmouse.hardware import Button, ButtonAction, RotaryDirection
from musicmouse.library.models import Album
from musicmouse.reactions.registry import on from musicmouse.reactions.registry import on
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -74,6 +76,14 @@ def play_figure(event: PlayFigureRequested, app: App) -> None:
app.player.play_from_start() app.player.play_from_start()
def _start_album(app: App, album: Album, track_index: int) -> None:
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
# Playlist object either way, because both come from the library index.
playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist()
app.player.set_playlist(playlist or album.to_playlist())
app.player.play_track(track_index)
@on(PlayAlbumRequested) @on(PlayAlbumRequested)
def play_album(event: PlayAlbumRequested, app: App) -> None: def play_album(event: PlayAlbumRequested, app: App) -> None:
"""Play any album in the library. This is the web front-end's way in.""" """Play any album in the library. This is the web front-end's way in."""
@@ -81,12 +91,17 @@ def play_album(event: PlayAlbumRequested, app: App) -> None:
if album is None: if album is None:
_log.warning("No album %r in the library", event.album_id) _log.warning("No album %r in the library", event.album_id)
return return
_start_album(app, album, event.track_index)
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
# Playlist object either way, because both come from the library index. @on(PlaySeriesLatestRequested)
playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist() def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
app.player.set_playlist(playlist or album.to_playlist()) """Play the newest episode of a podcast show - the IR remote's number-key way in."""
app.player.play_track(event.track_index) album = app.library.latest_episode(event.series)
if album is None:
_log.warning("No episodes for series %r", event.series)
return
_start_album(app, album, 0)
@on(PlaylistFinished) @on(PlaylistFinished)

View File

@@ -16,3 +16,5 @@ from musicmouse.reactions.registry import on
def connection_changed(event: ConnectionChanged, app: App) -> None: def connection_changed(event: ConnectionChanged, app: App) -> None:
if event.target == "mqtt": if event.target == "mqtt":
app.state.mqtt_connected = event.connected app.state.mqtt_connected = event.connected
elif event.target == "lirc":
app.state.lirc_connected = event.connected

View File

@@ -0,0 +1,6 @@
"""IR remote control, via lircd's TCP socket."""
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
from musicmouse.services.lirc.service import LircService
__all__ = ["LircButtonEvent", "LircService", "parse_line"]

View File

@@ -0,0 +1,37 @@
"""lircd's classic network protocol: one line per button press or repeat.
A line looks like::
0000000000001781 00 BTN_1 Hauppauge
that is ``<code> <repeat, hex> <button name> <remote name>``. ``repeat`` is ``00`` for
the first press and increments while the button is held - lircd has no separate
key-up event, just repeats stopping.
"""
from __future__ import annotations
from dataclasses import dataclass
__all__ = ["LircButtonEvent", "parse_line"]
@dataclass(frozen=True, slots=True)
class LircButtonEvent:
code: str
repeat: int
button: str
remote: str
def parse_line(line: str) -> LircButtonEvent | None:
"""One broadcast line, or ``None`` if it does not look like one."""
parts = line.strip().split()
if len(parts) != 4:
return None
code, repeat_hex, button, remote = parts
try:
repeat = int(repeat_hex, 16)
except ValueError:
return None
return LircButtonEvent(code=code, repeat=repeat, button=button, remote=remote)

View File

@@ -0,0 +1,140 @@
"""The IR remote: a TCP client for lircd, translated into the same intents every other
front-end emits.
Connect, read lines until the link drops, wait, repeat - the same reconnect shape as
:class:`~musicmouse.devices.serial_link.SerialLink`, over a plain socket instead of a
serial port because lircd speaks its classic protocol on a bare TCP connection.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from musicmouse.app import App
from musicmouse.clock import Clock, RealClock
from musicmouse.config import Digit, LircConfig
from musicmouse.events import (
ConnectionChanged,
IntentEvent,
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayRequested,
PlaySeriesLatestRequested,
PrevTrackRequested,
SetVolumeRequested,
VolumeChangeRequested,
)
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
_log = logging.getLogger(__name__)
__all__ = ["LircService"]
#: Acted on only at repeat 0 (the first press) - holding the button must not replay it.
_TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
"KEY_PLAY": lambda: PlayRequested(source="lirc"),
# No separate "stop" concept exists in this player; the remote's stop button just
# pauses, like its pause button.
"KEY_PAUSE": lambda: PauseRequested(source="lirc"),
"KEY_STOP": lambda: PauseRequested(source="lirc"),
"KEY_PREVIOUS": lambda: PrevTrackRequested(source="lirc"),
"KEY_REWIND": lambda: PrevTrackRequested(source="lirc"),
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
}
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
#: encoder (see ``reactions.playback.rotary_turned``).
_VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1}
_DIGITS: dict[str, Digit] = {
"BTN_0": "0",
"BTN_1": "1",
"BTN_2": "2",
"BTN_3": "3",
"BTN_4": "4",
"BTN_5": "5",
"BTN_6": "6",
"BTN_7": "7",
"BTN_8": "8",
"BTN_9": "9",
}
class LircService:
name = "lirc"
def __init__(self, app: App, config: LircConfig, *, clock: Clock | None = None) -> None:
self.app = app
self.config = config
self._clock = clock or RealClock()
#: Volume to restore on the next mute press, remembered the way the frontend's
#: own mute toggle does (App.tsx) rather than via any new Player API.
self._pre_mute_volume: int | None = None
async def run(self) -> None:
"""Connect, read lines until the link drops, wait, repeat. Runs until cancelled."""
while True:
try:
await self._session()
except OSError as exc:
_log.warning(
"lircd link to %s:%d lost (%s); retrying in %gs",
self.config.host,
self.config.port,
exc,
self.config.reconnect_interval,
)
await self._clock.sleep(self.config.reconnect_interval)
async def _session(self) -> None:
reader, writer = await asyncio.open_connection(self.config.host, self.config.port)
_log.info("Connected to lircd at %s:%d", self.config.host, self.config.port)
self.app.bus.emit(ConnectionChanged(target="lirc", connected=True, source="lirc"))
try:
while True:
raw = await reader.readline()
if not raw:
return
event = parse_line(raw.decode(errors="replace"))
if event is None or event.remote != self.config.remote_name:
continue
self._handle(event)
finally:
writer.close()
self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc"))
def _handle(self, event: LircButtonEvent) -> None:
if event.button in _TRANSPORT:
if event.repeat == 0:
self.app.bus.emit(_TRANSPORT[event.button]())
elif event.button in _VOLUME:
step = self.app.config.general.volume_increment * _VOLUME[event.button]
self.app.bus.emit(VolumeChangeRequested(delta=step, source="lirc"))
elif event.button == "KEY_MUTE":
if event.repeat == 0:
self._toggle_mute()
elif (digit := _DIGITS.get(event.button)) is not None and event.repeat == 0:
self._play_digit(digit)
def _toggle_mute(self) -> None:
player = self.app.player
if player.volume > 0:
self._pre_mute_volume = player.volume
self.app.bus.emit(SetVolumeRequested(volume=0, source="lirc"))
else:
restore = self._pre_mute_volume or self.app.config.general.initial_volume
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
def _play_digit(self, digit: Digit) -> None:
slot = self.app.config.remote.get(digit)
if slot is None:
return
if slot.target_kind == "album":
self.app.bus.emit(
PlayAlbumRequested(album_id=slot.target, track_index=0, source="lirc")
)
else:
self.app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source="lirc"))

View File

@@ -20,7 +20,7 @@ from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisc
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from musicmouse.app import App from musicmouse.app import App
from musicmouse.config import HaConfig from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
from musicmouse.events import ( from musicmouse.events import (
IntentEvent, IntentEvent,
NextTrackRequested, NextTrackRequested,
@@ -32,13 +32,17 @@ from musicmouse.events import (
SetVolumeRequested, SetVolumeRequested,
) )
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.schemas import ( from musicmouse.services.web.schemas import (
AlbumOut, AlbumOut,
HaConfigOut, HaConfigOut,
HaDeviceOut, HaDeviceOut,
LibraryOut, LibraryOut,
LircConfigOut,
PlayerStateOut, PlayerStateOut,
PlayIn, PlayIn,
RemoteMappingIn,
RemoteMappingOut,
SeekIn, SeekIn,
SettingsIn, SettingsIn,
SettingsOut, SettingsOut,
@@ -196,6 +200,38 @@ def build_router(
await hub.broadcast_state() await hub.broadcast_state()
return read_settings(general) return read_settings(general)
# -------------------------------------------------------------- IR remote
@router.get("/lirc")
def get_lirc_config() -> LircConfigOut:
if app.config.general.lirc is None:
raise HTTPException(status_code=404, detail="lirc not configured")
return LircConfigOut(connected=app.state.lirc_connected)
@router.get("/remote/mapping")
def get_remote_mapping() -> RemoteMappingOut:
return read_mapping(app.config.remote, app.library)
@router.put("/remote/mapping")
async def put_remote_mapping(body: RemoteMappingIn) -> RemoteMappingOut:
resolved: dict[Digit, RemoteSlotConfig] = {}
for digit, slot in body.slots.items():
found = (
app.library.get(slot.target)
if slot.target_kind == "album"
else app.library.latest_episode(slot.target)
)
if found is None:
raise HTTPException(
status_code=422,
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
)
resolved[digit] = RemoteSlotConfig(target_kind=slot.target_kind, target=slot.target)
app.config.remote = resolved
await asyncio.to_thread(write_mapping, config_path, resolved)
return read_mapping(app.config.remote, app.library)
# --------------------------------------------------------------- room control # --------------------------------------------------------------- room control
# #
# The browser never sees the Home Assistant token: it stays server-side, attached # The browser never sees the Home Assistant token: it stays server-side, attached

View File

@@ -0,0 +1,48 @@
"""Reading and writing the IR remote's number-key mapping.
Same job as :mod:`musicmouse.services.web.settings`, for a dict-shaped config section
rather than flat scalars: the ``remote:`` top-level key, not something under
``general``.
"""
from __future__ import annotations
from pathlib import Path
from musicmouse.config import Digit, RemoteSlotConfig
from musicmouse.library import MusicLibrary
from musicmouse.services.web.schemas import RemoteMappingOut, RemoteSlotOut
from musicmouse.services.web.settings import atomic_write, load_document
__all__ = ["read_mapping", "write_mapping"]
def _resolve(slot: RemoteSlotConfig, library: MusicLibrary) -> str | None:
if slot.target_kind == "album":
album = library.get(slot.target)
else:
album = library.latest_episode(slot.target)
return album.id if album else None
def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -> RemoteMappingOut:
slots = [
RemoteSlotOut(
digit=digit,
target_kind=slot.target_kind,
target=slot.target,
resolved_album_id=_resolve(slot, library),
)
for digit, slot in sorted(remote.items())
]
return RemoteMappingOut(slots=slots)
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
document = load_document(path)
remote = {
digit: {"target_kind": slot.target_kind, "target": slot.target}
for digit, slot in mapping.items()
}
document["remote"] = remote
atomic_write(path, document)

View File

@@ -8,8 +8,11 @@ configured ceiling is a parent's business, not a child's, so it never crosses th
from __future__ import annotations from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from musicmouse.config import Digit
from musicmouse.library import Album from musicmouse.library import Album
from musicmouse.library.analysis import TrackAnalysis from musicmouse.library.analysis import TrackAnalysis
@@ -18,8 +21,13 @@ __all__ = [
"HaConfigOut", "HaConfigOut",
"HaDeviceOut", "HaDeviceOut",
"LibraryOut", "LibraryOut",
"LircConfigOut",
"PlayIn", "PlayIn",
"PlayerStateOut", "PlayerStateOut",
"RemoteMappingIn",
"RemoteMappingOut",
"RemoteSlotIn",
"RemoteSlotOut",
"SeekIn", "SeekIn",
"SettingsIn", "SettingsIn",
"SettingsOut", "SettingsOut",
@@ -125,6 +133,7 @@ class TrackDetailOut(BaseModel):
class ConnectionOut(BaseModel): class ConnectionOut(BaseModel):
firmware: bool firmware: bool
mqtt: bool mqtt: bool
lirc: bool
class PlayerStateOut(BaseModel): class PlayerStateOut(BaseModel):
@@ -188,3 +197,36 @@ class HaConfigOut(BaseModel):
devices: list[HaDeviceOut] devices: list[HaDeviceOut]
scenes: list[HaDeviceOut] scenes: list[HaDeviceOut]
class LircConfigOut(BaseModel):
"""Presence-only, like ``HaConfigOut``: there is nothing secret in a host/port,
but the frontend only needs to know whether the remote is set up and connected."""
connected: bool
class RemoteSlotOut(BaseModel):
digit: str
target_kind: Literal["album", "series"]
target: str
#: The album this slot resolves to *right now* - the fixed album for "album" slots,
#: or today's newest episode for "series" slots. `None` when the target no longer
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
#: broken-assignment state instead of silently dropping it.
resolved_album_id: str | None
class RemoteMappingOut(BaseModel):
slots: list[RemoteSlotOut]
class RemoteSlotIn(BaseModel):
target_kind: Literal["album", "series"]
target: str
class RemoteMappingIn(BaseModel):
"""Full replacement, like ``SettingsIn``: a digit absent here becomes unassigned."""
slots: dict[Digit, RemoteSlotIn]

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Any
from ruamel.yaml import YAML from ruamel.yaml import YAML
@@ -20,7 +21,14 @@ from musicmouse.services.web.schemas import SettingsIn, SettingsOut
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
__all__ = ["read_settings", "to_device_volume", "to_percent", "write_settings"] __all__ = [
"atomic_write",
"load_document",
"read_settings",
"to_device_volume",
"to_percent",
"write_settings",
]
def to_device_volume(percent: int, general: GeneralConfig) -> int: def to_device_volume(percent: int, general: GeneralConfig) -> int:
@@ -47,30 +55,40 @@ def read_settings(general: GeneralConfig) -> SettingsOut:
) )
def write_settings(path: Path, settings: SettingsIn) -> None: def _yaml() -> YAML:
"""Patch the settings into ``config.yml`` in place.
Loaded round-trip rather than with the safe loader :mod:`musicmouse.config` uses,
so the file keeps its comments, ordering and formatting - a config that explains
itself is worth more than one this could rewrite from scratch. Written through a
sibling temp file so an interrupted save cannot truncate the real one.
"""
yaml = YAML(typ="rt") yaml = YAML(typ="rt")
yaml.preserve_quotes = True yaml.preserve_quotes = True
return yaml
def load_document(path: Path) -> Any:
"""The config file, round-trip parsed so it keeps its comments and formatting.
Shared by every writer that patches ``config.yml`` in place - a config that
explains itself is worth more than one a save could rewrite from scratch.
"""
with path.open(encoding="utf-8") as handle: with path.open(encoding="utf-8") as handle:
document = yaml.load(handle) return _yaml().load(handle)
general = document["general"]
for key, value in settings.model_dump().items():
general[key] = value
def atomic_write(path: Path, document: Any) -> None:
"""Write a round-trip-loaded document back, through a sibling temp file so an
interrupted save cannot truncate the real one."""
temp = path.with_name(f"{path.name}.tmp{os.getpid()}") temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
try: try:
with temp.open("w", encoding="utf-8") as handle: with temp.open("w", encoding="utf-8") as handle:
yaml.dump(document, handle) _yaml().dump(document, handle)
temp.replace(path) temp.replace(path)
except BaseException: except BaseException:
temp.unlink(missing_ok=True) temp.unlink(missing_ok=True)
raise raise
_log.info("Wrote settings to %s", path) _log.info("Wrote settings to %s", path)
def write_settings(path: Path, settings: SettingsIn) -> None:
"""Patch the settings into ``config.yml`` in place."""
document = load_document(path)
general = document["general"]
for key, value in settings.model_dump().items():
general[key] = value
atomic_write(path, document)

View File

@@ -43,5 +43,6 @@ def snapshot(app: App) -> PlayerStateOut:
connected=ConnectionOut( connected=ConnectionOut(
firmware=app.mouse.connected, firmware=app.mouse.connected,
mqtt=app.state.mqtt_connected, mqtt=app.state.mqtt_connected,
lirc=app.state.lirc_connected,
), ),
) )

View File

@@ -56,6 +56,46 @@ def test_ha_section_is_optional(config_dir: Path) -> None:
assert load_config(write_config(config_dir, VALID_CONFIG)).general.ha is None assert load_config(write_config(config_dir, VALID_CONFIG)).general.ha is None
def test_lirc_section_is_optional(config_dir: Path) -> None:
assert load_config(write_config(config_dir, VALID_CONFIG)).general.lirc is None
data = _config(lirc={"host": "musicmouse-pi.local"})
config = load_config(write_config(config_dir, data))
assert config.general.lirc is not None
assert config.general.lirc.port == 2222
assert config.general.lirc.remote_name == "Hauppauge"
def test_remote_mapping_is_optional_and_empty_by_default(config_dir: Path) -> None:
assert load_config(write_config(config_dir, VALID_CONFIG)).remote == {}
def test_remote_mapping_loads_album_and_series_slots(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["remote"] = {
"1": {"target_kind": "album", "target": "abc123"},
"7": {"target_kind": "series", "target": "Wissen macht Ah"},
}
config = load_config(write_config(config_dir, data))
assert config.remote["1"].target_kind == "album"
assert config.remote["1"].target == "abc123"
assert config.remote["7"].target_kind == "series"
def test_remote_mapping_rejects_an_out_of_range_digit(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["remote"] = {"10": {"target_kind": "album", "target": "abc123"}}
message = _error(config_dir, data)
assert "remote" in message
def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["remote"] = {"1": {"target_kind": "playlist", "target": "abc123"}}
message = _error(config_dir, data)
assert "remote" in message
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None: def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
data = _config( data = _config(
ha={ ha={

View File

@@ -128,6 +128,28 @@ async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None:
assert [a.title for a in episodes] == ["Neu", "Alt"] assert [a.title for a in episodes] == ["Neu", "Alt"]
async def test_latest_episode_finds_the_newest_episode_of_a_show(config_dir: Path) -> None:
library = await build(config_dir)
latest = library.latest_episode("Wissen macht Ah")
assert latest is not None
assert latest.title == "Neu"
async def test_latest_episode_is_none_for_an_unknown_show(config_dir: Path) -> None:
library = await build(config_dir)
assert library.latest_episode("no such show") is None
async def test_latest_episode_ignores_series_that_are_not_episode_unit(config_dir: Path) -> None:
"""A music/book section groups by `series` too (an audiobook's character), but it
is a folder-unit shelf, not an episode-unit one - `latest_episode` must not treat
an audiobook as if it had "episodes"."""
library = await build(config_dir)
audiobook = album_named(library, "Conni in den Bergen")
assert audiobook.series is not None
assert library.latest_episode(audiobook.series) is None
async def test_other_sections_keep_filename_order(config_dir: Path) -> None: async def test_other_sections_keep_filename_order(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Kinderparty Lieder") album = album_named(await build(config_dir), "Kinderparty Lieder")
assert [track.path.name for track in album.tracks] == [ assert [track.path.name for track in album.tracks] == [

View File

@@ -0,0 +1,342 @@
"""The IR remote: line parsing, and the service against a scripted lircd."""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from musicmouse.clock import FakeClock
from musicmouse.config import LircConfig, RemoteSlotConfig, load_config
from musicmouse.events import (
ConnectionChanged,
Event,
IntentEvent,
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayRequested,
PlaySeriesLatestRequested,
PrevTrackRequested,
VolumeChangeRequested,
)
from musicmouse.services.lirc import LircService
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
from musicmouse.simulator.harness import Simulation, build_simulation
from tests.conftest import VALID_CONFIG, write_config
# ---------------------------------------------------------------------- parse_line
def test_a_valid_line_is_parsed() -> None:
assert parse_line("0000000000001781 00 BTN_1 Hauppauge") == LircButtonEvent(
code="0000000000001781", repeat=0, button="BTN_1", remote="Hauppauge"
)
def test_a_repeat_field_is_hexadecimal() -> None:
event = parse_line("0000000000001781 0a BTN_1 Hauppauge")
assert event is not None
assert event.repeat == 10
@pytest.mark.parametrize(
"line",
[
"",
"0000000000001781 00 BTN_1", # missing remote
"0000000000001781 00 BTN_1 Hauppauge extra", # extra token
"0000000000001781 zz BTN_1 Hauppauge", # non-hex repeat
],
)
def test_malformed_lines_are_rejected(line: str) -> None:
assert parse_line(line) is None
def test_surrounding_whitespace_is_ignored() -> None:
assert parse_line(" 0000000000001781 00 BTN_1 Hauppauge \n") is not None
# --------------------------------------------------------------------------- service
class ScriptedLircd:
"""A tiny stand-in for lircd: accepts one connection at a time and lets the test
push lines to whoever is currently connected."""
def __init__(self) -> None:
self._writer: asyncio.StreamWriter | None = None
self._connected = asyncio.Event()
self.connection_count = 0
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
self._writer = writer
self.connection_count += 1
self._connected.set()
with contextlib.suppress(asyncio.CancelledError):
await asyncio.Event().wait() # held open until the test drops it
async def wait_connected(self) -> None:
await self._connected.wait()
async def send(self, line: str) -> None:
await self._connected.wait()
assert self._writer is not None
self._writer.write(f"{line}\n".encode())
await self._writer.drain()
# Real socket I/O, not the fake clock: give the reader a moment to actually
# see the bytes before the caller checks what happened.
await asyncio.sleep(0.02)
def drop(self) -> None:
"""Simulate the link dying: close the socket, and wait for a new connect."""
assert self._writer is not None
self._writer.close()
self._writer = None
self._connected = asyncio.Event()
@pytest.fixture
async def lircd() -> AsyncIterator[tuple[ScriptedLircd, asyncio.base_events.Server]]:
station = ScriptedLircd()
server = await asyncio.start_server(station._handle, "127.0.0.1", 0)
try:
yield station, server
finally:
server.close()
# The scripted connection handler holds its connection open forever (it does
# not know the test is done), so `wait_closed()` alone would hang here.
server.close_clients()
await server.wait_closed()
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
config = load_config(write_config(config_dir, VALID_CONFIG))
# Real time for the socket I/O itself, virtual time for the reconnect sleep - tests
# drive that explicitly via `sim.clock.advance()` and drain the bus after.
simulation = await build_simulation(config, clock=FakeClock())
try:
yield simulation
finally:
await simulation.aclose()
def _lirc_config(port: int, *, reconnect_interval: float = 5.0) -> LircConfig:
return LircConfig(host="127.0.0.1", port=port, reconnect_interval=reconnect_interval)
async def _run_service(sim: Simulation, config: LircConfig) -> asyncio.Task[None]:
service = LircService(sim.app, config, clock=sim.clock)
return asyncio.create_task(service.run())
async def test_transport_and_volume_buttons_emit_intents(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe_all(seen.append)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 KEY_PLAY Hauppauge")
await station.send("0 00 KEY_PAUSE Hauppauge")
await station.send("0 00 KEY_STOP Hauppauge")
await station.send("0 00 KEY_PREVIOUS Hauppauge")
await station.send("0 00 KEY_FORWARD Hauppauge")
await station.send("0 00 KEY_VOLUMEUP Hauppauge")
await sim.bus.drain()
finally:
task.cancel()
kinds = [type(event) for event in seen if isinstance(event, IntentEvent)]
assert kinds == [
PlayRequested,
PauseRequested,
PauseRequested,
PrevTrackRequested,
NextTrackRequested,
VolumeChangeRequested,
]
async def test_events_from_another_remote_are_ignored(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe_all(seen.append)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 KEY_PLAY small_led_remote")
await sim.bus.drain()
finally:
task.cancel()
assert not any(isinstance(event, IntentEvent) for event in seen)
async def test_transport_buttons_only_act_on_the_first_press(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe(NextTrackRequested, seen.append)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 KEY_FORWARD Hauppauge")
await station.send("0 01 KEY_FORWARD Hauppauge")
await station.send("0 02 KEY_FORWARD Hauppauge")
await sim.bus.drain()
finally:
task.cancel()
assert len(seen) == 1
async def test_volume_buttons_act_on_every_repeat(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[VolumeChangeRequested] = []
sim.bus.subscribe(VolumeChangeRequested, seen.append)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 KEY_VOLUMEDOWN Hauppauge")
await station.send("0 01 KEY_VOLUMEDOWN Hauppauge")
await station.send("0 02 KEY_VOLUMEDOWN Hauppauge")
await sim.bus.drain()
finally:
task.cancel()
assert len(seen) == 3
assert all(event.delta < 0 for event in seen)
async def test_mute_toggles_and_restores(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
sim.player.set_volume(37)
await sim.bus.drain()
await station.send("0 00 KEY_MUTE Hauppauge")
await sim.bus.drain()
assert sim.player.volume == 0
await station.send("0 00 KEY_MUTE Hauppauge")
await sim.bus.drain()
assert sim.player.volume == 37
finally:
task.cancel()
async def test_a_digit_with_no_assignment_does_nothing(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe(PlayAlbumRequested, seen.append)
sim.bus.subscribe(PlaySeriesLatestRequested, seen.append)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 BTN_5 Hauppauge")
await sim.bus.drain()
finally:
task.cancel()
assert seen == []
async def test_an_album_digit_plays_from_the_beginning(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
album = next(a for a in sim.app.library.albums if a.title == "Kinderparty Lieder")
sim.app.config.remote = {"3": RemoteSlotConfig(target_kind="album", target=album.id)}
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 BTN_3 Hauppauge")
await sim.bus.drain()
finally:
task.cancel()
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == album.id
assert sim.player.track_index == 0
async def test_a_series_digit_plays_the_latest_episode(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
sim.app.config.remote = {"7": RemoteSlotConfig(target_kind="series", target="Wissen macht Ah")}
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 BTN_7 Hauppauge")
await sim.bus.drain()
finally:
task.cancel()
assert sim.player.playlist is not None
played = sim.app.library.get(sim.player.playlist.album_id)
assert played is not None
assert played.title == "Neu"
async def test_a_dropped_link_reconnects(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[ConnectionChanged] = []
sim.bus.subscribe(ConnectionChanged, seen.append)
task = await _run_service(sim, _lirc_config(port, reconnect_interval=10.0))
try:
await station.wait_connected()
await sim.bus.drain()
assert station.connection_count == 1
station.drop()
await asyncio.sleep(0.05) # let the client notice EOF
await sim.bus.drain()
assert isinstance(sim.clock, FakeClock)
await sim.clock.advance(10.0)
await station.wait_connected()
await sim.bus.drain()
assert station.connection_count == 2
assert [event.connected for event in seen] == [True, False, True]
finally:
task.cancel()

View File

@@ -18,7 +18,7 @@ from musicmouse.effects import (
EffectStaticConfig, EffectStaticConfig,
EffectSwipeAndChange, EffectSwipeAndChange,
) )
from musicmouse.events import Event, LedEffectChanged, VolumeChanged from musicmouse.events import Event, LedEffectChanged, PlaySeriesLatestRequested, VolumeChanged
from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton
from musicmouse.simulator.driver import SimulatorDriver from musicmouse.simulator.driver import SimulatorDriver
from musicmouse.simulator.harness import Simulation, build_simulation from musicmouse.simulator.harness import Simulation, build_simulation
@@ -367,3 +367,24 @@ async def test_reconnecting_restores_the_leds(
# equivalent-but-not-equal colours. # equivalent-but-not-equal colours.
assert restored.as_bytes() == before.as_bytes() assert restored.as_bytes() == before.as_bytes()
assert sim.transport.brightness() == pytest.approx(0.5) assert sim.transport.brightness() == pytest.approx(0.5)
# --------------------------------------------------------------------- podcast shows
async def test_playing_a_series_starts_its_newest_episode(sim: Simulation) -> None:
await sim.bus.emit_and_wait(PlaySeriesLatestRequested(series="Wissen macht Ah", source="web"))
playlist = sim.player.playlist
assert playlist is not None
played = sim.app.library.get(playlist.album_id)
assert played is not None
assert played.title == "Neu"
assert sim.player.is_playing
async def test_playing_an_unknown_series_does_nothing(sim: Simulation) -> None:
await sim.bus.emit_and_wait(PlaySeriesLatestRequested(series="no such show", source="web"))
assert sim.player.playlist is None
assert not sim.player.is_playing

View File

@@ -0,0 +1,155 @@
"""The IR remote's number-key mapping, over the REST API."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import httpx2
import pytest
from musicmouse.config import WebConfig, load_config
from musicmouse.services.web.service import build_app
from musicmouse.simulator.harness import Simulation, build_simulation
from tests.conftest import VALID_CONFIG, write_config
#: A config file with comments, to prove saving does not flatten them.
COMMENTED = """\
# The MusicMouse config.
general:
library:
root: music
cache: .cache
serial_port: "/dev/ttyUSB0"
alsa_device: simulate
min_volume: 0
max_volume: 60
initial_volume: 40
figures:
fuchs:
id: "04a1b2c3d4"
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
eule:
id: "04b2c3d4e5"
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
"""
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
config = load_config(write_config(config_dir, VALID_CONFIG))
simulation = await build_simulation(config)
try:
yield simulation
finally:
await simulation.aclose()
@pytest.fixture
async def client(sim: Simulation, config_dir: Path) -> AsyncIterator[httpx2.AsyncClient]:
path = config_dir / "config.yml"
path.write_text(COMMENTED, encoding="utf-8")
api, hub = build_app(sim.app, WebConfig(), path)
hub.start()
transport = httpx2.ASGITransport(app=api)
try:
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
yield http
finally:
hub.stop()
await api.state.ha_client.aclose()
async def album_id(sim: Simulation, title: str) -> str:
return next(a.id for a in sim.app.library.albums if a.title == title)
# ------------------------------------------------------------------------------ lirc
async def test_lirc_is_a_404_when_unconfigured(client: httpx2.AsyncClient) -> None:
assert (await client.get("/api/lirc")).status_code == 404
# --------------------------------------------------------------------------- reading
async def test_the_mapping_is_empty_by_default(client: httpx2.AsyncClient) -> None:
body = (await client.get("/api/remote/mapping")).json()
assert body["slots"] == []
# --------------------------------------------------------------------------- writing
async def test_saving_an_album_slot_round_trips(
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping", json={"slots": {"3": {"target_kind": "album", "target": target}}}
)
assert response.status_code == 200
slots = response.json()["slots"]
assert slots == [
{"digit": "3", "target_kind": "album", "target": target, "resolved_album_id": target}
]
text = (config_dir / "config.yml").read_text(encoding="utf-8")
assert "# The MusicMouse config." in text # comments survive
reloaded = load_config(config_dir / "config.yml")
assert reloaded.remote["3"].target == target
async def test_saving_a_series_slot_resolves_to_the_latest_episode(
client: httpx2.AsyncClient,
) -> None:
response = await client.put(
"/api/remote/mapping",
json={"slots": {"7": {"target_kind": "series", "target": "Wissen macht Ah"}}},
)
assert response.status_code == 200
slot = response.json()["slots"][0]
assert slot["target_kind"] == "series"
assert slot["target"] == "Wissen macht Ah"
assert slot["resolved_album_id"] is not None
async def test_saving_clears_digits_left_out(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
await client.put(
"/api/remote/mapping", json={"slots": {"1": {"target_kind": "album", "target": target}}}
)
response = await client.put("/api/remote/mapping", json={"slots": {}})
assert response.json()["slots"] == []
async def test_an_unresolvable_album_target_is_rejected(client: httpx2.AsyncClient) -> None:
response = await client.put(
"/api/remote/mapping",
json={"slots": {"1": {"target_kind": "album", "target": "no-such-album"}}},
)
assert response.status_code == 422
assert "1" in response.json()["detail"]
async def test_an_unresolvable_series_target_is_rejected(client: httpx2.AsyncClient) -> None:
response = await client.put(
"/api/remote/mapping",
json={"slots": {"2": {"target_kind": "series", "target": "no such show"}}},
)
assert response.status_code == 422
async def test_a_rejected_save_leaves_the_file_alone(
client: httpx2.AsyncClient, config_dir: Path
) -> None:
before = (config_dir / "config.yml").read_text(encoding="utf-8")
await client.put(
"/api/remote/mapping",
json={"slots": {"1": {"target_kind": "album", "target": "no-such-album"}}},
)
assert (config_dir / "config.yml").read_text(encoding="utf-8") == before

View File

@@ -150,7 +150,7 @@ async def test_state_starts_idle(client: Client) -> None:
assert state["playing"] is False assert state["playing"] is False
assert state["album_id"] is None assert state["album_id"] is None
assert state["active_figure"] is None assert state["active_figure"] is None
assert state["connected"] == {"firmware": True, "mqtt": False} assert state["connected"] == {"firmware": True, "mqtt": False, "lirc": False}
async def test_play_loads_the_album_and_starts_it(client: Client, sim: Simulation) -> None: async def test_play_loads_the_album_and_starts_it(client: Client, sim: Simulation) -> None:

View File

@@ -9,7 +9,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "./api/client"; import { api } from "./api/client";
import type { Album, HaConfig } from "./api/types"; import type { Album, HaConfig, RemoteMapping, RemoteSlotInput } from "./api/types";
import { AlbumModal } from "./components/AlbumModal"; import { AlbumModal } from "./components/AlbumModal";
import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience"; import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience";
import { AmbienceDebugOverlay } from "./components/AmbienceDebugOverlay"; import { AmbienceDebugOverlay } from "./components/AmbienceDebugOverlay";
@@ -19,6 +19,7 @@ import { HelpOverlay } from "./components/HelpOverlay";
import { ParentPanel } from "./components/ParentPanel"; import { ParentPanel } from "./components/ParentPanel";
import { PlayerBar } from "./components/PlayerBar"; import { PlayerBar } from "./components/PlayerBar";
import { PlayView } from "./components/PlayView"; import { PlayView } from "./components/PlayView";
import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
import { RoomView } from "./components/RoomView"; import { RoomView } from "./components/RoomView";
import { useGridColumns } from "./hooks/useGridColumns"; import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary"; import { useLibrary } from "./hooks/useLibrary";
@@ -28,9 +29,16 @@ import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
import type { Action, UiState } from "./lib/keyboard"; import type { Action, UiState } from "./lib/keyboard";
import { handleKey, initialUiState } from "./lib/keyboard"; import { handleKey, initialUiState } from "./lib/keyboard";
import { playPop } from "./lib/pop"; import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote";
import type { Group, Results, SongHit } from "./lib/search"; import type { Group, Results, SongHit } from "./lib/search";
import { groupOf, results as computeResults } from "./lib/search"; import { groupOf, results as computeResults } from "./lib/search";
/** How long an armed "A" waits for the digit that completes the shortcut. */
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
/** How long a "taste zugewiesen" confirmation stays on screen. */
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;
@@ -63,6 +71,17 @@ export function App() {
void api.haConfig().then(setHaConfig); void api.haConfig().then(setHaConfig);
}, []); }, []);
// The remote key -> album/show mapping. Always fetched (unlike haConfig, this
// endpoint has no "not configured" state - it's just empty on a fresh install) so
// the assign popup and the keyboard shortcut always have something to read/merge.
const [remoteMapping, setRemoteMapping] = useState<RemoteMapping | null>(null);
const [assignPopupOpen, setAssignPopupOpen] = useState(false);
const [assignStatus, setAssignStatus] = useState<string | null>(null);
useEffect(() => {
void api.remoteMapping().then(setRemoteMapping);
}, []);
const state = connection.state; const state = connection.state;
useEffect(() => { useEffect(() => {
@@ -117,6 +136,50 @@ export function App() {
}, [connection, library.albums, play, results.albums, state]); }, [connection, library.albums, play, results.albums, state]);
/** The one place a keyboard action, a click or a tap all end up. */ /** The one place a keyboard action, a click or a tap all end up. */
// Read-modify-write the whole mapping, like ParentPanel does for settings: the
// backend takes a full replacement, not a per-slot patch.
const saveMapping = useCallback(
async (mutate: (slots: Record<string, RemoteSlotInput>) => void) => {
const slots: Record<string, RemoteSlotInput> = Object.fromEntries(
(remoteMapping?.slots ?? []).map((slot) => [
slot.digit,
{ target_kind: slot.target_kind, target: slot.target },
]),
);
mutate(slots);
const updated = await api.saveRemoteMapping(slots);
setRemoteMapping(updated);
return updated;
},
[remoteMapping],
);
const assignCurrentTo = useCallback(
async (digit: string) => {
if (!currentAlbum) return;
const target = targetForAlbum(currentAlbum);
try {
await saveMapping((slots) => {
slots[digit] = target;
});
setAssignStatus(`Taste ${digit}: „${currentAlbum.title}"`);
} catch {
setAssignStatus("Konnte nicht zugewiesen werden.");
}
setAssignPopupOpen(false);
},
[currentAlbum, saveMapping],
);
const clearSlot = useCallback(
(digit: string) => {
void saveMapping((slots) => {
delete slots[digit];
});
},
[saveMapping],
);
const run = useCallback( const run = useCallback(
(actions: Action[]) => { (actions: Action[]) => {
for (const action of actions) { for (const action of actions) {
@@ -130,6 +193,9 @@ export function App() {
case "toggle": case "toggle":
toggle(); toggle();
break; break;
case "assign":
void assignCurrentTo(action.digit);
break;
case "next": case "next":
playPop(260); playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") { if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
@@ -175,7 +241,7 @@ export function App() {
} }
} }
}, },
[connection, currentAlbum, play, setVolume, state, toggle], [assignCurrentTo, connection, currentAlbum, play, setVolume, state, toggle],
); );
// Held in a ref so the listener is installed once rather than on every state change. // Held in a ref so the listener is installed once rather than on every state change.
@@ -198,6 +264,22 @@ export function App() {
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, []); }, []);
// An abandoned "A" press disarms itself rather than leaving the app half-primed.
useEffect(() => {
if (!ui.assignPending) return;
const timer = setTimeout(
() => setUi((previous) => ({ ...previous, assignPending: false })),
ASSIGN_PENDING_TIMEOUT_MS,
);
return () => clearTimeout(timer);
}, [ui.assignPending]);
useEffect(() => {
if (!assignStatus) return;
const timer = setTimeout(() => setAssignStatus(null), ASSIGN_STATUS_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [assignStatus]);
const onEnterGroup = (group: Group, category: string | null) => { const onEnterGroup = (group: Group, category: string | null) => {
playPop(category ? 440 : 380); playPop(category ? 440 : 380);
setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 })); setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 }));
@@ -302,9 +384,43 @@ export function App() {
onMute={onMute} onMute={onMute}
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))} onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
onOpenAlbum={onOpenCurrentAlbum} onOpenAlbum={onOpenCurrentAlbum}
onOpenAssign={() => setAssignPopupOpen(true)}
assignPending={ui.assignPending}
/> />
)} )}
{assignPopupOpen && currentAlbum && (
<RemoteAssignPopup
album={currentAlbum}
mapping={remoteMapping}
albums={library.albums}
onAssign={(digit) => void assignCurrentTo(digit)}
onClear={clearSlot}
onClose={() => setAssignPopupOpen(false)}
/>
)}
{assignStatus && (
<div
style={{
position: "absolute",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
zIndex: 7,
background: "var(--ink)",
color: "#fff",
fontSize: 14,
fontWeight: 800,
padding: "10px 20px",
borderRadius: 999,
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
}}
>
{assignStatus}
</div>
)}
{ui.view === "room" && haConfig && ( {ui.view === "room" && haConfig && (
<RoomView <RoomView
config={haConfig} config={haConfig}

View File

@@ -1,6 +1,16 @@
/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */ /** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
import type { Album, HaConfig, HaEntityState, PlayerState, Settings, TrackDetail } from "./types"; import type {
Album,
HaConfig,
HaEntityState,
LircConfig,
PlayerState,
RemoteMapping,
RemoteSlotInput,
Settings,
TrackDetail,
} from "./types";
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api${path}`, { const response = await fetch(`/api${path}`, {
@@ -29,6 +39,15 @@ async function fetchHaConfig(): Promise<HaConfig | null> {
return (await response.json()) as HaConfig; return (await response.json()) as HaConfig;
} }
/** `null` means the IR remote isn't configured, not an error - same convention as
* `fetchHaConfig`. */
async function fetchLircConfig(): Promise<LircConfig | null> {
const response = await fetch("/api/lirc");
if (response.status === 404) return null;
if (!response.ok) throw new Error(`GET /lirc failed: ${response.status}`);
return (await response.json()) as LircConfig;
}
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable /** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
* right now" (the backend answers the latter with a 502) - the room page treats a * right now" (the backend answers the latter with a 502) - the room page treats a
* device with no state the same way either way, rather than crashing on a poll. */ * device with no state the same way either way, rather than crashing on a poll. */
@@ -80,6 +99,11 @@ export const api = {
post(`/ha/services/${domain}/${service}`, body), post(`/ha/services/${domain}/${service}`, body),
trackDetail: fetchTrackDetail, trackDetail: fetchTrackDetail,
lircConfig: fetchLircConfig,
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
}; };
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`; export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;

View File

@@ -72,7 +72,7 @@ export interface PlayerState {
/** Percent, 0..100. The device's configured range never leaves the backend. */ /** Percent, 0..100. The device's configured range never leaves the backend. */
volume: number; volume: number;
active_figure: string | null; active_figure: string | null;
connected: { firmware: boolean; mqtt: boolean }; connected: { firmware: boolean; mqtt: boolean; lirc: boolean };
} }
export interface Settings { export interface Settings {
@@ -107,3 +107,31 @@ export interface HaEntityState {
state: string; state: string;
attributes: Record<string, unknown>; attributes: Record<string, unknown>;
} }
export type RemoteTargetKind = "album" | "series";
/** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, always
* started from track 0. "series": `target` is a podcast show name (`Album.series`),
* resolved to that show's newest episode fresh on every press. `resolved_album_id` is
* what it plays *right now* - `null` when the target no longer resolves (a moved
* album, an unknown show). Mirrors `RemoteSlotOut`. */
export interface RemoteSlot {
digit: string;
target_kind: RemoteTargetKind;
target: string;
resolved_album_id: string | null;
}
export interface RemoteMapping {
slots: RemoteSlot[];
}
export interface RemoteSlotInput {
target_kind: RemoteTargetKind;
target: string;
}
/** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */
export interface LircConfig {
connected: boolean;
}

View File

@@ -10,6 +10,7 @@ const KEYS: Array<[caps: string[], label: string]> = [
[["?"], "Einzelne Titel suchen"], [["?"], "Einzelne Titel suchen"],
[["F1"], "Diese Hilfe"], [["F1"], "Diese Hilfe"],
[["TAB"], "Musik / Hörbücher / Podcasts"], [["TAB"], "Musik / Hörbücher / Podcasts"],
[["A", "0-9"], "Aktuellen Titel einer Fernbedienungs-Taste zuweisen"],
[["← ↑ ↓ →"], "Auswahl bewegen"], [["← ↑ ↓ →"], "Auswahl bewegen"],
[["ENTER"], "Auswahl abspielen"], [["ENTER"], "Auswahl abspielen"],
[["ESC"], "Schließen / Suche löschen"], [["ESC"], "Schließen / Suche löschen"],

View File

@@ -21,6 +21,10 @@ interface Props {
onMute: () => void; onMute: () => void;
onBrowse: () => void; onBrowse: () => void;
onOpenAlbum: () => void; onOpenAlbum: () => void;
/** Assigning the current album/show to a remote key. `assignPending` is true right
* after "A" is pressed, waiting for the digit that completes the shortcut. */
onOpenAssign: () => void;
assignPending: boolean;
} }
export function PlayView({ export function PlayView({
@@ -34,6 +38,8 @@ export function PlayView({
onMute, onMute,
onBrowse, onBrowse,
onOpenAlbum, onOpenAlbum,
onOpenAssign,
assignPending,
}: Props) { }: Props) {
const position = usePlaybackClock(state.position, state.playing); const position = usePlaybackClock(state.position, state.playing);
const book = album ? isBook(album) : false; const book = album ? isBook(album) : false;
@@ -245,6 +251,32 @@ export function PlayView({
</span> </span>
Zurück zur Suche Zurück zur Suche
</button> </button>
{album && (
<button
onClick={onOpenAssign}
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl)"
style={{
position: "absolute",
top: 28,
right: 84,
border: "none",
cursor: "pointer",
background: assignPending ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
borderRadius: 999,
padding: "11px 20px",
fontSize: 14,
fontWeight: 800,
color: assignPending ? "#fff" : "var(--ink)",
display: "flex",
alignItems: "center",
gap: 8,
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
}}
>
🎛 {assignPending ? "Zahl drücken …" : "Taste zuweisen"}
</button>
)}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,141 @@
/** "Which remote key?" - a simple 0-9 grid for assigning the currently playing album
* or podcast show to a number key on the IR remote. Same job as pressing "A" then a
* digit; this is the tap-only way in, for a viewer with no keyboard. */
import type { Album, RemoteMapping } from "../api/types";
import { remoteSlotView } from "../lib/remote";
import { Cover } from "./Cover";
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
interface Props {
album: Album;
mapping: RemoteMapping | null;
albums: Album[];
onAssign: (digit: string) => void;
onClear: (digit: string) => void;
onClose: () => void;
}
export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, onClose }: Props) {
const byDigit = new Map(mapping?.slots.map((slot) => [slot.digit, slot]));
return (
<div className="overlay" style={{ zIndex: 6 }} onClick={onClose}>
<div
className="sheet"
style={{ padding: "26px 30px", width: 420 }}
onClick={(event) => event.stopPropagation()}
>
<div style={{ fontSize: 17, fontWeight: 800, color: "var(--ink)", marginBottom: 4 }}>
🎛 Taste zuweisen
</div>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(45% 0.03 210)",
marginBottom: 16,
}}
>
{album.title}" antippen, welche Taste?
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(5, 1fr)",
gap: 10,
}}
>
{DIGITS.map((digit) => {
const view = remoteSlotView(byDigit.get(digit), albums);
return (
<div key={digit} style={{ position: "relative" }}>
<button
onClick={() => onAssign(digit)}
title={
view.state === "ok"
? `Taste ${digit}: ${view.album.title} → überschreiben`
: `Taste ${digit} zuweisen`
}
style={{
width: "100%",
aspectRatio: 1,
border: "none",
borderRadius: 14,
cursor: "pointer",
padding: 6,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 4,
background: view.state === "ok" ? "oklch(90% 0.04 210)" : "oklch(94% 0.01 210)",
overflow: "hidden",
}}
>
{view.state === "ok" ? (
<Cover album={view.album} size={40} radius="6px" />
) : (
<span
style={{
fontSize: 20,
fontWeight: 900,
color:
view.state === "missing" ? "oklch(55% 0.15 30)" : "oklch(60% 0.02 210)",
}}
>
{view.state === "missing" ? "" : "+"}
</span>
)}
<span style={{ fontSize: 13, fontWeight: 900, color: "var(--ink)" }}>
{digit}
</span>
</button>
{view.state !== "empty" && (
<button
onClick={(event) => {
event.stopPropagation();
onClear(digit);
}}
aria-label={`Taste ${digit} freigeben`}
style={{
position: "absolute",
top: -6,
right: -6,
width: 22,
height: 22,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: "var(--ink)",
color: "#fff",
fontSize: 12,
fontWeight: 900,
lineHeight: 1,
}}
>
×
</button>
)}
</div>
);
})}
</div>
<div
style={{
marginTop: 16,
fontSize: 12,
fontWeight: 600,
color: "oklch(50% 0.03 210 / .8)",
textAlign: "center",
}}
>
Tippe daneben, um zu schließen
</div>
</div>
</div>
);
}

View File

@@ -231,6 +231,43 @@ describe("keyboard", () => {
}); });
}); });
describe("remote key assignment", () => {
const playing: UiState = { ...initialUiState, view: "play" };
it("A arms assignment while something is playing", () => {
expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
expect(press("A", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
});
it("a plain a still types into search everywhere else", () => {
expect(press("a", initialUiState)).toEqual([
{ type: "ui", patch: { search: "a", view: "browse", selIndex: 0 } },
]);
expect(
press("a", { ...initialUiState, view: "play", openAlbumId: "b" }),
).toEqual([{ type: "ui", patch: { search: "a", view: "browse", selIndex: 0 } }]);
});
it("a following digit assigns and disarms", () => {
const armed: UiState = { ...playing, assignPending: true };
expect(press("5", armed)).toEqual([
{ type: "assign", digit: "5" },
{ type: "ui", patch: { assignPending: false } },
]);
});
it("a digit does the normal thing when nothing is armed", () => {
expect(press("5", initialUiState)).toEqual([
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
]);
});
it("ESC disarms before doing anything else", () => {
const armed: UiState = { ...playing, assignPending: true };
expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: false } }]);
});
});
describe("selectionAt", () => { describe("selectionAt", () => {
it("walks songs, then categories, then albums in one flat index space", () => { it("walks songs, then categories, then albums in one flat index space", () => {
const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" }; const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" };

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import type { Album, RemoteSlot } from "../../api/types";
import { remoteSlotView, targetForAlbum } from "../remote";
function album(id: string, over: Partial<Album> = {}): Album {
return {
id,
section: "Musik",
kind: "music",
title: `Album ${id}`,
artist: "Kinderparty",
series: null,
figure: null,
category: "Kinderparty",
colors: ["#111111", "#222222", "#333333"],
has_cover: false,
duration: 120,
tracks: [],
...over,
};
}
describe("targetForAlbum", () => {
it("targets the album itself for music and audiobooks", () => {
expect(targetForAlbum(album("a"))).toEqual({ target_kind: "album", target: "a" });
expect(targetForAlbum(album("b", { kind: "book", series: "Conni" }))).toEqual({
target_kind: "album",
target: "b",
});
});
it("targets the show, not the episode, for a podcast", () => {
const episode = album("ep1", {
kind: "book",
section: "Kinderpodcasts",
series: "Wissen macht Ah",
});
expect(targetForAlbum(episode)).toEqual({
target_kind: "series",
target: "Wissen macht Ah",
});
});
});
describe("remoteSlotView", () => {
const albums = [album("resolved-id")];
it("is empty when nothing is assigned", () => {
expect(remoteSlotView(undefined, albums)).toEqual({ state: "empty" });
});
it("is ok when the slot resolves to a known album", () => {
const slot: RemoteSlot = {
digit: "3",
target_kind: "album",
target: "resolved-id",
resolved_album_id: "resolved-id",
};
expect(remoteSlotView(slot, albums)).toEqual({
state: "ok",
slot,
album: albums[0],
});
});
it("is missing when the backend could not resolve the target", () => {
const slot: RemoteSlot = {
digit: "4",
target_kind: "series",
target: "no such show",
resolved_album_id: null,
};
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
});
it("is missing when the resolved album has since left the library", () => {
const slot: RemoteSlot = {
digit: "5",
target_kind: "album",
target: "gone",
resolved_album_id: "gone",
};
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
});
});

View File

@@ -23,6 +23,9 @@ export interface UiState {
openAlbumId: string | null; openAlbumId: string | null;
showHelp: boolean; showHelp: boolean;
cols: number; cols: number;
/** Armed by `A` on the play screen: the next digit assigns what's playing to that
* remote key instead of doing whatever it would normally do. */
assignPending: boolean;
} }
export const initialUiState: UiState = { export const initialUiState: UiState = {
@@ -35,6 +38,7 @@ export const initialUiState: UiState = {
openAlbumId: null, openAlbumId: null,
showHelp: false, showHelp: false,
cols: 4, cols: 4,
assignPending: false,
}; };
export type Action = export type Action =
@@ -45,11 +49,20 @@ export type Action =
| { type: "previous" } | { type: "previous" }
| { type: "volume"; delta: number } | { type: "volume"; delta: number }
| { type: "seek"; delta: number } | { type: "seek"; delta: number }
| { type: "pop"; freq: number }; | { type: "pop"; freq: number }
| { type: "assign"; digit: string };
/** 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;
/** The default behaviour for a plain character key: type it into search. Shared by
* `default` and by `a`/`A`, which only sometimes means something else. */
function typeIntoSearch(state: UiState, key: string): Action[] {
return SEARCHABLE.test(key)
? [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }]
: [];
}
const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"]; const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
export const VOLUME_STEP = 10; export const VOLUME_STEP = 10;
@@ -89,6 +102,9 @@ function moveSelection(state: UiState, results: Results, dx: number, dy: number)
/** ESC peels one layer off at a time rather than dumping you back at the top. */ /** ESC peels one layer off at a time rather than dumping you back at the top. */
function escape(state: UiState): Action[] { function escape(state: UiState): Action[] {
if (state.assignPending) {
return [{ type: "ui", patch: { assignPending: false } }];
}
if (state.showHelp || state.openAlbumId !== null) { if (state.showHelp || state.openAlbumId !== null) {
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }]; return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
} }
@@ -135,6 +151,12 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
} }
} }
// Armed by "A" below; the next digit assigns what's playing to that remote key
// instead of whatever it would normally do (typing into search, seeking, ...).
if (state.assignPending && /^[0-9]$/.test(key)) {
return [{ type: "assign", digit: key }, { type: "ui", patch: { assignPending: false } }];
}
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp; const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
switch (key) { switch (key) {
@@ -201,10 +223,15 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
const chosen = selectionAt(results, state.selIndex); const chosen = selectionAt(results, state.selIndex);
return chosen ? [chosen] : []; return chosen ? [chosen] : [];
} }
default: case "a":
if (SEARCHABLE.test(key)) { case "A":
return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }]; // Only while something is loaded on the play screen - everywhere else "a" is
// just the first letter of a search, like any other key.
if (state.view === "play" && !state.showHelp && state.openAlbumId === null) {
return [{ type: "ui", patch: { assignPending: true } }];
} }
return []; return typeIntoSearch(state, key);
default:
return typeIntoSearch(state, key);
} }
} }

41
web/src/lib/remote.ts Normal file
View File

@@ -0,0 +1,41 @@
/** Assigning the currently playing album to a number key on the IR remote.
Kept pure so the popup and the `A` + digit keyboard shortcut can share one place that
decides what a press actually assigns, and so a slot's display state - "nothing here",
"here's what plays", "this used to point somewhere real" - needs no branching logic
inside the component that renders it.
*/
import type { Album, RemoteSlot, RemoteSlotInput } from "../api/types";
import { groupOf } from "./search";
/** What pressing "assign" on `album` actually stores.
*
* A podcast is not itself one playable thing in this library - each episode is its
* own album - so a podcast assignment targets the *show* (its series name), resolved
* to whatever is newest on every press. Everything else targets the album itself,
* always started from track 0.
*/
export function targetForAlbum(album: Album): RemoteSlotInput {
if (groupOf(album) === "podcasts" && album.series) {
return { target_kind: "series", target: album.series };
}
return { target_kind: "album", target: album.id };
}
export type RemoteSlotView =
| { state: "empty" }
| { state: "ok"; slot: RemoteSlot; album: Album }
| { state: "missing"; slot: RemoteSlot };
/** How one digit's card should render, given the current mapping and the library. */
export function remoteSlotView(
slot: RemoteSlot | undefined,
albums: Album[],
): RemoteSlotView {
if (!slot) return { state: "empty" };
const album = slot.resolved_album_id
? albums.find((candidate) => candidate.id === slot.resolved_album_id)
: undefined;
return album ? { state: "ok", slot, album } : { state: "missing", slot };
}