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:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
6
python-backend/musicmouse/services/lirc/__init__.py
Normal file
6
python-backend/musicmouse/services/lirc/__init__.py
Normal 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"]
|
||||||
37
python-backend/musicmouse/services/lirc/protocol.py
Normal file
37
python-backend/musicmouse/services/lirc/protocol.py
Normal 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)
|
||||||
140
python-backend/musicmouse/services/lirc/service.py
Normal file
140
python-backend/musicmouse/services/lirc/service.py
Normal 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"))
|
||||||
@@ -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
|
||||||
|
|||||||
48
python-backend/musicmouse/services/web/remote_settings.py
Normal file
48
python-backend/musicmouse/services/web/remote_settings.py
Normal 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)
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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] == [
|
||||||
|
|||||||
342
python-backend/tests/test_lirc.py
Normal file
342
python-backend/tests/test_lirc.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
155
python-backend/tests/test_remote_api.py
Normal file
155
python-backend/tests/test_remote_api.py
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
120
web/src/App.tsx
120
web/src/App.tsx
@@ -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}
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
141
web/src/components/RemoteAssignPopup.tsx
Normal file
141
web/src/components/RemoteAssignPopup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
|||||||
86
web/src/lib/__tests__/remote.test.ts
Normal file
86
web/src/lib/__tests__/remote.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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] : [];
|
||||||
}
|
}
|
||||||
|
case "a":
|
||||||
|
case "A":
|
||||||
|
// 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 typeIntoSearch(state, key);
|
||||||
default:
|
default:
|
||||||
if (SEARCHABLE.test(key)) {
|
return typeIntoSearch(state, key);
|
||||||
return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
web/src/lib/remote.ts
Normal file
41
web/src/lib/remote.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user