From 747c39030304dd2b26a44ab33821051f72e0fab6 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Fri, 11 Sep 2026 08:31:28 +0200 Subject: [PATCH] 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 --- python-backend/config.yml.example | 29 ++ python-backend/musicmouse/__main__.py | 7 + python-backend/musicmouse/app.py | 3 + python-backend/musicmouse/config.py | 40 ++ python-backend/musicmouse/events.py | 17 +- python-backend/musicmouse/library/__init__.py | 20 + .../musicmouse/reactions/playback.py | 25 +- python-backend/musicmouse/reactions/status.py | 2 + .../musicmouse/services/lirc/__init__.py | 6 + .../musicmouse/services/lirc/protocol.py | 37 ++ .../musicmouse/services/lirc/service.py | 140 +++++++ python-backend/musicmouse/services/web/api.py | 38 +- .../services/web/remote_settings.py | 48 +++ .../musicmouse/services/web/schemas.py | 42 +++ .../musicmouse/services/web/settings.py | 46 ++- .../musicmouse/services/web/state.py | 1 + python-backend/tests/test_config.py | 40 ++ python-backend/tests/test_library.py | 22 ++ python-backend/tests/test_lirc.py | 342 ++++++++++++++++++ python-backend/tests/test_reactions.py | 23 +- python-backend/tests/test_remote_api.py | 155 ++++++++ python-backend/tests/test_web.py | 2 +- web/src/App.tsx | 120 +++++- web/src/api/client.ts | 26 +- web/src/api/types.ts | 30 +- web/src/components/HelpOverlay.tsx | 1 + web/src/components/PlayView.tsx | 32 ++ web/src/components/RemoteAssignPopup.tsx | 141 ++++++++ web/src/lib/__tests__/keyboard.test.ts | 37 ++ web/src/lib/__tests__/remote.test.ts | 86 +++++ web/src/lib/keyboard.ts | 37 +- web/src/lib/remote.ts | 41 +++ 32 files changed, 1603 insertions(+), 33 deletions(-) create mode 100644 python-backend/musicmouse/services/lirc/__init__.py create mode 100644 python-backend/musicmouse/services/lirc/protocol.py create mode 100644 python-backend/musicmouse/services/lirc/service.py create mode 100644 python-backend/musicmouse/services/web/remote_settings.py create mode 100644 python-backend/tests/test_lirc.py create mode 100644 python-backend/tests/test_remote_api.py create mode 100644 web/src/components/RemoteAssignPopup.tsx create mode 100644 web/src/lib/__tests__/remote.test.ts create mode 100644 web/src/lib/remote.ts diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example index b908f0c..aa9ef54 100644 --- a/python-backend/config.yml.example +++ b/python-backend/config.yml.example @@ -66,6 +66,18 @@ general: # Built frontend to serve at /. Omit to expose only the JSON API. 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. # The backend exposes three lights, a player sensor, a volume slider, transport # buttons, device triggers for every button/touch area, and a tag scanner. @@ -124,3 +136,20 @@ figures: id: "04b2c3d4e5" colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"] 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" diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index 29c2111..bb231c1 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -36,6 +36,7 @@ from musicmouse.library import MusicLibrary from musicmouse.library.analysis import build_analyzer from musicmouse.reactions import register_all from musicmouse.services.base import Service +from musicmouse.services.lirc import LircService from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.podcasts import PodcastFeedService from musicmouse.services.web import WebService @@ -330,6 +331,12 @@ def _build_services( else: 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` # 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) diff --git a/python-backend/musicmouse/app.py b/python-backend/musicmouse/app.py index b9babf2..3f6f167 100644 --- a/python-backend/musicmouse/app.py +++ b/python-backend/musicmouse/app.py @@ -31,6 +31,9 @@ class AppState: #: readable off the transport; a broker's is not, so it is remembered here. mqtt_connected: bool = False + #: Whether the lircd TCP link for the IR remote is currently reachable. + lirc_connected: bool = False + @dataclass class App: diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py index 10d2b1d..2c82c60 100644 --- a/python-backend/musicmouse/config.py +++ b/python-backend/musicmouse/config.py @@ -33,18 +33,24 @@ __all__ = [ "SIMULATE", "Config", "ConfigError", + "Digit", "FigureColors", "FigureConfig", "GeneralConfig", "HaConfig", "HaDeviceConfig", "LibraryConfig", + "LircConfig", "MqttConfig", + "RemoteSlotConfig", "WebConfig", "format_validation_error", "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") #: 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) +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): """Where the music lives. @@ -221,6 +243,7 @@ class GeneralConfig(_Strict): mqtt: MqttConfig | None = None web: WebConfig | None = None ha: HaConfig | None = None + lirc: LircConfig | None = None min_volume: int = Field(default=0, 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" +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): general: GeneralConfig 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") def _check_unique_tag_ids(self) -> Self: diff --git a/python-backend/musicmouse/events.py b/python-backend/musicmouse/events.py index 3a45a38..b967765 100644 --- a/python-backend/musicmouse/events.py +++ b/python-backend/musicmouse/events.py @@ -35,8 +35,10 @@ __all__ = [ "LedEffectRequested", "NextTrackRequested", "PauseRequested", + "PlayAlbumRequested", "PlayFigureRequested", "PlayRequested", + "PlaySeriesLatestRequested", "PlaybackChanged", "PlaylistFinished", "PrevTrackRequested", @@ -51,7 +53,7 @@ __all__ = [ "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) @@ -186,6 +188,17 @@ class PlayAlbumRequested(IntentEvent): 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) class SeekRequested(IntentEvent): #: Seconds from the start of the current track. @@ -257,5 +270,5 @@ class LedEffectChanged(StateEvent): @dataclass(frozen=True, slots=True, kw_only=True) class ConnectionChanged(StateEvent): - target: Literal["firmware", "mqtt"] + target: Literal["firmware", "mqtt", "lirc"] connected: bool diff --git a/python-backend/musicmouse/library/__init__.py b/python-backend/musicmouse/library/__init__.py index 5f9d059..789a7d6 100644 --- a/python-backend/musicmouse/library/__init__.py +++ b/python-backend/musicmouse/library/__init__.py @@ -124,6 +124,26 @@ class MusicLibrary: 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: album = self.get(identifier) if album is None or not 0 <= index < len(album.tracks): diff --git a/python-backend/musicmouse/reactions/playback.py b/python-backend/musicmouse/reactions/playback.py index 0d8f575..e9e53cc 100644 --- a/python-backend/musicmouse/reactions/playback.py +++ b/python-backend/musicmouse/reactions/playback.py @@ -19,6 +19,7 @@ from musicmouse.events import ( PlayFigureRequested, PlaylistFinished, PlayRequested, + PlaySeriesLatestRequested, PrevTrackRequested, RotaryTurned, SeekRequested, @@ -26,6 +27,7 @@ from musicmouse.events import ( VolumeChangeRequested, ) from musicmouse.hardware import Button, ButtonAction, RotaryDirection +from musicmouse.library.models import Album from musicmouse.reactions.registry import on _log = logging.getLogger(__name__) @@ -74,6 +76,14 @@ def play_figure(event: PlayFigureRequested, app: App) -> None: 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) def play_album(event: PlayAlbumRequested, app: App) -> None: """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: _log.warning("No album %r in the library", event.album_id) 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. - 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(event.track_index) + +@on(PlaySeriesLatestRequested) +def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None: + """Play the newest episode of a podcast show - the IR remote's number-key way in.""" + 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) diff --git a/python-backend/musicmouse/reactions/status.py b/python-backend/musicmouse/reactions/status.py index a456f0b..6497375 100644 --- a/python-backend/musicmouse/reactions/status.py +++ b/python-backend/musicmouse/reactions/status.py @@ -16,3 +16,5 @@ from musicmouse.reactions.registry import on def connection_changed(event: ConnectionChanged, app: App) -> None: if event.target == "mqtt": app.state.mqtt_connected = event.connected + elif event.target == "lirc": + app.state.lirc_connected = event.connected diff --git a/python-backend/musicmouse/services/lirc/__init__.py b/python-backend/musicmouse/services/lirc/__init__.py new file mode 100644 index 0000000..9313c8f --- /dev/null +++ b/python-backend/musicmouse/services/lirc/__init__.py @@ -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"] diff --git a/python-backend/musicmouse/services/lirc/protocol.py b/python-backend/musicmouse/services/lirc/protocol.py new file mode 100644 index 0000000..4b0445f --- /dev/null +++ b/python-backend/musicmouse/services/lirc/protocol.py @@ -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 `` + + {album && ( + + )} ); diff --git a/web/src/components/RemoteAssignPopup.tsx b/web/src/components/RemoteAssignPopup.tsx new file mode 100644 index 0000000..96b7654 --- /dev/null +++ b/web/src/components/RemoteAssignPopup.tsx @@ -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 ( +
+
event.stopPropagation()} + > +
+ 🎛️ Taste zuweisen +
+
+ „{album.title}" antippen, welche Taste? +
+ +
+ {DIGITS.map((digit) => { + const view = remoteSlotView(byDigit.get(digit), albums); + return ( +
+ + {view.state !== "empty" && ( + + )} +
+ ); + })} +
+ +
+ Tippe daneben, um zu schließen +
+
+
+ ); +} diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts index fdaefdd..33ebdab 100644 --- a/web/src/lib/__tests__/keyboard.test.ts +++ b/web/src/lib/__tests__/keyboard.test.ts @@ -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", () => { it("walks songs, then categories, then albums in one flat index space", () => { const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" }; diff --git a/web/src/lib/__tests__/remote.test.ts b/web/src/lib/__tests__/remote.test.ts new file mode 100644 index 0000000..20e39c4 --- /dev/null +++ b/web/src/lib/__tests__/remote.test.ts @@ -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 { + 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 }); + }); +}); diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts index 1d82603..4716f49 100644 --- a/web/src/lib/keyboard.ts +++ b/web/src/lib/keyboard.ts @@ -23,6 +23,9 @@ export interface UiState { openAlbumId: string | null; showHelp: boolean; 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 = { @@ -35,6 +38,7 @@ export const initialUiState: UiState = { openAlbumId: null, showHelp: false, cols: 4, + assignPending: false, }; export type Action = @@ -45,11 +49,20 @@ export type Action = | { type: "previous" } | { type: "volume"; 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. */ 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"]; 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. */ function escape(state: UiState): Action[] { + if (state.assignPending) { + return [{ type: "ui", patch: { assignPending: false } }]; + } if (state.showHelp || state.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; switch (key) { @@ -201,10 +223,15 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac const chosen = selectionAt(results, state.selIndex); return chosen ? [chosen] : []; } - default: - if (SEARCHABLE.test(key)) { - return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }]; + 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 []; + return typeIntoSearch(state, key); + default: + return typeIntoSearch(state, key); } } diff --git a/web/src/lib/remote.ts b/web/src/lib/remote.ts new file mode 100644 index 0000000..31ca3fa --- /dev/null +++ b/web/src/lib/remote.ts @@ -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 }; +}