diff --git a/python-backend/README.md b/python-backend/README.md index 29b2d13..491f40f 100644 --- a/python-backend/README.md +++ b/python-backend/README.md @@ -217,6 +217,8 @@ copied to the device. | `POST /api/play` | `{album_id, track_index?}` | | `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport | | `POST /api/volume` | `{percent}` or `{delta_percent}` | +| `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits | +| `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show | | `GET` `PUT /api/settings` | parent mode | Two decisions worth knowing: diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example index 1ba379c..198298d 100644 --- a/python-backend/config.yml.example +++ b/python-backend/config.yml.example @@ -178,8 +178,10 @@ figures: # 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: album -> starts from the first track (music, audiobooks), or from +# track_index (0-based) when given - one song, followed by +# the rest of the album. 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". @@ -190,3 +192,7 @@ remote: "2": target_kind: series target: "Wissen macht Ah" + "3": + target_kind: album + target: "3f9a0c12ab44" + track_index: 4 diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py index 8a4e09d..4712052 100644 --- a/python-backend/musicmouse/config.py +++ b/python-backend/musicmouse/config.py @@ -347,8 +347,8 @@ class FigureConfig(_Strict): 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"``: ``target`` is an ``Album.id`` - a music album or an audiobook, started + from ``track_index``. ``"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. @@ -356,6 +356,15 @@ class RemoteSlotConfig(_Strict): target_kind: Literal["album", "series"] target: str + #: ``"album"`` only: start at this track instead of the first, then carry on with + #: the rest of the album - one song assigned to a key. + track_index: int = Field(default=0, ge=0) + + @model_validator(mode="after") + def _check_track_index(self) -> Self: + if self.target_kind == "series" and self.track_index: + raise ValueError("track_index only applies to album slots") + return self class Config(_Strict): diff --git a/python-backend/musicmouse/events.py b/python-backend/musicmouse/events.py index 2a48e64..6bb9422 100644 --- a/python-backend/musicmouse/events.py +++ b/python-backend/musicmouse/events.py @@ -15,12 +15,15 @@ from __future__ import annotations import time from dataclasses import dataclass, field -from typing import Literal, TypeAlias +from typing import TYPE_CHECKING, Literal, TypeAlias from musicmouse.effects import LedEffect from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton from musicmouse.media import Playlist, Track +if TYPE_CHECKING: + from musicmouse.config import Digit + __all__ = [ "ActiveFigureChanged", "ButtonEvent", @@ -37,6 +40,7 @@ __all__ = [ "PauseRequested", "PlayAlbumRequested", "PlayFigureRequested", + "PlayRemoteSlotRequested", "PlayRequested", "PlaySeriesLatestRequested", "PlaybackChanged", @@ -198,6 +202,17 @@ class PlaySeriesLatestRequested(IntentEvent): series: str +@dataclass(frozen=True, slots=True, kw_only=True) +class PlayRemoteSlotRequested(IntentEvent): + """Play whatever the top-level ``remote:`` mapping assigns to a number key. + + The IR remote's digits emit this, and so do the number keys of the web front-end's + keyboard - one reaction resolves the slot for both. + """ + + digit: Digit + + @dataclass(frozen=True, slots=True, kw_only=True) class SeekRequested(IntentEvent): #: Seconds from the start of the current track. diff --git a/python-backend/musicmouse/reactions/playback.py b/python-backend/musicmouse/reactions/playback.py index 2ad9d90..a2241e3 100644 --- a/python-backend/musicmouse/reactions/playback.py +++ b/python-backend/musicmouse/reactions/playback.py @@ -18,6 +18,7 @@ from musicmouse.events import ( PlayAlbumRequested, PlayFigureRequested, PlaylistFinished, + PlayRemoteSlotRequested, PlayRequested, PlaySeriesLatestRequested, PreviousTrackRequested, @@ -104,6 +105,27 @@ def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None: _start_album(app, album, 0) +@on(PlayRemoteSlotRequested) +def play_remote_slot(event: PlayRemoteSlotRequested, app: App) -> None: + """Play what a number key is assigned - pressed on the IR remote or a keyboard. + + A slot naming one song starts the album there; the rest of the album follows, as + for any other start mid-album. + """ + slot = app.config.remote.get(event.digit) + if slot is None: + _log.debug("Number key %s is not assigned", event.digit) + return + if slot.target_kind == "series": + app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source=event.source)) + return + album = app.library.get(slot.target) + if album is None: + _log.warning("Number key %s: no album %r in the library", event.digit, slot.target) + return + _start_album(app, album, slot.track_index) + + @on(PlaylistFinished) def playlist_finished(_event: PlaylistFinished, app: App) -> None: # Nothing was left half-played, so the next placement starts from the top. diff --git a/python-backend/musicmouse/services/lirc/service.py b/python-backend/musicmouse/services/lirc/service.py index 7843f63..89661c5 100644 --- a/python-backend/musicmouse/services/lirc/service.py +++ b/python-backend/musicmouse/services/lirc/service.py @@ -21,9 +21,8 @@ from musicmouse.events import ( IntentEvent, NextTrackRequested, PauseRequested, - PlayAlbumRequested, + PlayRemoteSlotRequested, PlayRequested, - PlaySeriesLatestRequested, PreviousTrackRequested, SetVolumeRequested, ToggleScreenRequested, @@ -156,12 +155,4 @@ class LircService: 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")) + self.app.bus.emit(PlayRemoteSlotRequested(digit=digit, source="lirc")) diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index a8216e6..c45f05b 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -26,6 +26,7 @@ from musicmouse.events import ( NextTrackRequested, PauseRequested, PlayAlbumRequested, + PlayRemoteSlotRequested, PlayRequested, PreviousTrackRequested, SeekRequested, @@ -290,6 +291,12 @@ def build_router( raise HTTPException(status_code=404, detail="lirc not configured") return LircConfigOut(connected=app.state.lirc_connected) + @router.post("/remote/press/{digit}", status_code=204) + def press_remote(digit: Digit) -> Response: + # A keyboard's number keys, doing what the IR remote's do. An unassigned key + # is not an error - the remote's own press of it is silent too. + return emit(PlayRemoteSlotRequested(digit=digit, source="web")) + @router.get("/remote/mapping") def get_remote_mapping() -> RemoteMappingOut: return read_mapping(app.config.remote, app.library) @@ -308,7 +315,19 @@ def build_router( 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) + if slot.target_kind == "series" and slot.track_index: + raise HTTPException( + status_code=422, + detail=f"key {digit}: a podcast show has no fixed song to start at", + ) + if slot.track_index >= len(found.tracks): + raise HTTPException( + status_code=422, + detail=f"key {digit}: {slot.target!r} has no track {slot.track_index}", + ) + resolved[digit] = RemoteSlotConfig( + target_kind=slot.target_kind, target=slot.target, track_index=slot.track_index + ) app.config.remote = resolved await asyncio.to_thread(write_mapping, config_path, resolved) diff --git a/python-backend/musicmouse/services/web/remote_settings.py b/python-backend/musicmouse/services/web/remote_settings.py index 0125d95..b40a749 100644 --- a/python-backend/musicmouse/services/web/remote_settings.py +++ b/python-backend/musicmouse/services/web/remote_settings.py @@ -32,6 +32,7 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) - target_kind=slot.target_kind, target=slot.target, resolved_album_id=_resolve(slot, library), + track_index=slot.track_index, ) for digit, slot in sorted(remote.items()) ] @@ -40,9 +41,12 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) - 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() - } + remote: dict[str, dict[str, str | int]] = {} + for digit, slot in mapping.items(): + entry: dict[str, str | int] = {"target_kind": slot.target_kind, "target": slot.target} + # Only written when it says something, so a whole-album slot reads as before. + if slot.track_index: + entry["track_index"] = slot.track_index + remote[digit] = entry document["remote"] = remote atomic_write(path, document) diff --git a/python-backend/musicmouse/services/web/schemas.py b/python-backend/musicmouse/services/web/schemas.py index e7dd0e9..fd258d6 100644 --- a/python-backend/musicmouse/services/web/schemas.py +++ b/python-backend/musicmouse/services/web/schemas.py @@ -281,6 +281,8 @@ class RemoteSlotOut(BaseModel): #: 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 + #: "album" slots only: the song the key starts at; 0 for the whole album. + track_index: int = 0 class RemoteMappingOut(BaseModel): @@ -290,6 +292,7 @@ class RemoteMappingOut(BaseModel): class RemoteSlotIn(BaseModel): target_kind: Literal["album", "series"] target: str + track_index: int = Field(default=0, ge=0) class RemoteMappingIn(BaseModel): diff --git a/python-backend/tests/test_config.py b/python-backend/tests/test_config.py index 3eea5c5..fb60493 100644 --- a/python-backend/tests/test_config.py +++ b/python-backend/tests/test_config.py @@ -96,6 +96,24 @@ def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None assert "remote" in message +def test_a_remote_slot_can_name_a_song(config_dir: Path) -> None: + data = copy.deepcopy(VALID_CONFIG) + data["remote"] = { + "1": {"target_kind": "album", "target": "abc123", "track_index": 4}, + "2": {"target_kind": "album", "target": "abc123"}, + } + config = load_config(write_config(config_dir, data)) + assert config.remote["1"].track_index == 4 + assert config.remote["2"].track_index == 0 + + +def test_a_series_slot_cannot_name_a_song(config_dir: Path) -> None: + data = copy.deepcopy(VALID_CONFIG) + data["remote"] = {"7": {"target_kind": "series", "target": "Wissen", "track_index": 1}} + message = _error(config_dir, data) + assert "track_index" in message + + def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None: data = _config( ha={ diff --git a/python-backend/tests/test_lirc.py b/python-backend/tests/test_lirc.py index 2832dd7..c783c8d 100644 --- a/python-backend/tests/test_lirc.py +++ b/python-backend/tests/test_lirc.py @@ -383,6 +383,29 @@ async def test_an_album_digit_plays_from_the_beginning( assert sim.player.track_index == 0 +async def test_a_song_digit_plays_from_that_song( + 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, track_index=1) + } + + 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 == 1 + + async def test_a_series_digit_plays_the_latest_episode( lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation ) -> None: diff --git a/python-backend/tests/test_remote_api.py b/python-backend/tests/test_remote_api.py index d5083f2..07b5251 100644 --- a/python-backend/tests/test_remote_api.py +++ b/python-backend/tests/test_remote_api.py @@ -8,7 +8,7 @@ from pathlib import Path import httpx2 import pytest -from musicmouse.config import WebConfig, load_config +from musicmouse.config import RemoteSlotConfig, 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 @@ -93,13 +93,93 @@ async def test_saving_an_album_slot_round_trips( assert response.status_code == 200 slots = response.json()["slots"] assert slots == [ - {"digit": "3", "target_kind": "album", "target": target, "resolved_album_id": target} + { + "digit": "3", + "target_kind": "album", + "target": target, + "resolved_album_id": target, + "track_index": 0, + } ] 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 + assert "track_index" not in text # a whole-album slot reads as it always did + + +async def test_saving_a_song_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": {"4": {"target_kind": "album", "target": target, "track_index": 1}}}, + ) + assert response.status_code == 200 + assert response.json()["slots"][0]["track_index"] == 1 + assert (await client.get("/api/remote/mapping")).json()["slots"][0]["track_index"] == 1 + assert sim.app.config.remote["4"].track_index == 1 + + reloaded = load_config(config_dir / "config.yml") + assert reloaded.remote["4"].track_index == 1 + + +async def test_a_song_past_the_end_of_the_album_is_rejected( + client: httpx2.AsyncClient, sim: Simulation +) -> None: + target = await album_id(sim, "Kinderparty Lieder") + response = await client.put( + "/api/remote/mapping", + json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 2}}}, + ) + assert response.status_code == 422 + assert "no track 2" in response.json()["detail"] + + +async def test_a_song_of_a_series_is_rejected(client: httpx2.AsyncClient) -> None: + response = await client.put( + "/api/remote/mapping", + json={ + "slots": { + "7": {"target_kind": "series", "target": "Wissen macht Ah", "track_index": 1} + } + }, + ) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- pressing + + +async def test_pressing_a_key_plays_its_song_and_then_the_rest( + client: httpx2.AsyncClient, sim: Simulation +) -> None: + target = await album_id(sim, "Kinderparty Lieder") + sim.app.config.remote = { + "4": RemoteSlotConfig(target_kind="album", target=target, track_index=1) + } + response = await client.post("/api/remote/press/4") + assert response.status_code == 204 + await sim.bus.drain() + + assert sim.player.playlist is not None + assert sim.player.playlist.album_id == target + assert len(sim.player.playlist) == 2 # the whole album, not just the one song + assert sim.player.track_index == 1 + + +async def test_pressing_an_unassigned_key_does_nothing( + client: httpx2.AsyncClient, sim: Simulation +) -> None: + assert (await client.post("/api/remote/press/5")).status_code == 204 + await sim.bus.drain() + assert sim.player.playlist is None + + +async def test_pressing_a_non_digit_is_rejected(client: httpx2.AsyncClient) -> None: + assert (await client.post("/api/remote/press/x")).status_code == 422 async def test_saving_a_series_slot_resolves_to_the_latest_episode( diff --git a/web/src/App.tsx b/web/src/App.tsx index 69a0300..379340a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -37,7 +37,7 @@ import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables"; import type { Action, UiState } from "./lib/keyboard"; import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard"; import { playPop } from "./lib/pop"; -import { targetForAlbum } from "./lib/remote"; +import { canAssignSong, targetForAlbum } from "./lib/remote"; import type { Group, Mode, Results, SongHit } from "./lib/search"; import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search"; import { SKIN } from "./lib/skins"; @@ -228,7 +228,7 @@ export function App() { const slots: Record = Object.fromEntries( (remoteMapping?.slots ?? []).map((slot) => [ slot.digit, - { target_kind: slot.target_kind, target: slot.target }, + { target_kind: slot.target_kind, target: slot.target, track_index: slot.track_index }, ]), ); mutate(slots); @@ -240,20 +240,35 @@ export function App() { ); const assignCurrentTo = useCallback( - async (digit: string) => { + async (digit: string, fromTrack: boolean) => { if (!currentAlbum) return; - const target = targetForAlbum(currentAlbum); + const song = fromTrack && canAssignSong(currentAlbum) ? (state?.track_index ?? 0) : 0; + const target = targetForAlbum(currentAlbum, song); + const songTitle = target.track_index ? currentAlbum.tracks[song]?.title : undefined; try { await saveMapping((slots) => { slots[digit] = target; }); - setAssignStatus(`Taste ${digit}: „${currentAlbum.title}"`); + setAssignStatus(`Taste ${digit}: „${songTitle ?? currentAlbum.title}"`); } catch { setAssignStatus("Konnte nicht zugewiesen werden."); } setAssignPopupOpen(false); }, - [currentAlbum, saveMapping], + [currentAlbum, saveMapping, state?.track_index], + ); + + const pressRemote = useCallback( + (digit: string) => { + // Shown at once, like `play`, when the key is known to play something; an empty + // key stays where it is, as the remote's own press of it does nothing either. + const slot = remoteMapping?.slots.find((candidate) => candidate.digit === digit); + if (slot?.resolved_album_id) { + setUi((previous) => ({ ...previous, page: "music", view: "play", openAlbumId: null, showHelp: false })); + } + void api.pressRemote(digit); + }, + [remoteMapping], ); const clearSlot = useCallback( @@ -279,7 +294,10 @@ export function App() { toggle(); break; case "assign": - void assignCurrentTo(action.digit); + void assignCurrentTo(action.digit, action.fromTrack); + break; + case "remote": + pressRemote(action.digit); break; case "next": playPop(260); @@ -330,6 +348,7 @@ export function App() { currentAlbum, onMute, play, + pressRemote, seekTo, setVolume, skipPodcast, @@ -374,7 +393,7 @@ export function App() { useEffect(() => { if (!ui.assignPending) return; const timer = setTimeout( - () => setUi((previous) => ({ ...previous, assignPending: false })), + () => setUi((previous) => ({ ...previous, assignPending: null })), ASSIGN_PENDING_TIMEOUT_MS, ); return () => clearTimeout(timer); @@ -536,7 +555,7 @@ export function App() { onMute={onMute} onOpenAlbum={onOpenCurrentAlbum} onOpenAssign={() => setAssignPopupOpen(true)} - assignPending={ui.assignPending} + assignPending={ui.assignPending !== null} /> )} @@ -545,7 +564,8 @@ export function App() { album={currentAlbum} mapping={remoteMapping} albums={library.albums} - onAssign={(digit) => void assignCurrentTo(digit)} + currentTrackIndex={state?.track_index ?? 0} + onAssign={(digit, fromTrack) => void assignCurrentTo(digit, fromTrack)} onClear={clearSlot} onClose={() => setAssignPopupOpen(false)} /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index edb09ad..b08ef7e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -111,6 +111,7 @@ export const api = { trackDetail: fetchTrackDetail, lircConfig: fetchLircConfig, + pressRemote: (digit: string) => post(`/remote/press/${digit}`), remoteMapping: () => request("/remote/mapping"), saveRemoteMapping: (slots: Record) => request("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 07b6e2d..b870e77 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -127,8 +127,9 @@ export interface HaEntityState { 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`), +/** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, started + * from `track_index` (0 for the whole album; otherwise one song, then the rest of the + * album). "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`. */ @@ -137,6 +138,7 @@ export interface RemoteSlot { target_kind: RemoteTargetKind; target: string; resolved_album_id: string | null; + track_index: number; } export interface RemoteMapping { @@ -146,6 +148,7 @@ export interface RemoteMapping { export interface RemoteSlotInput { target_kind: RemoteTargetKind; target: string; + track_index?: number; } /** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */ diff --git a/web/src/components/HelpOverlay.tsx b/web/src/components/HelpOverlay.tsx index fb2ed20..442edbf 100644 --- a/web/src/components/HelpOverlay.tsx +++ b/web/src/components/HelpOverlay.tsx @@ -12,6 +12,7 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string [["⇧J", "⇧K"], "Leiser / lauter"], [["⇧M"], "Stumm"], [["⇧←", "⇧→"], "15 Sekunden zurück / vor"], + [["0-9"], "Wie die Zahlen auf der Fernbedienung"], ], ], [ @@ -35,7 +36,8 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string [ "Sonstiges", [ - [["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"], + [["A", "0-9"], "Album auf Fernbedienungs-Taste legen"], + [["⇧A", "0-9"], "Nur dieses Lied auf die Taste legen"], [["ENTF"], "Bildschirm aus / an"], [["F1"], "Diese Hilfe"], [["ESC"], "Schließen / Suche löschen"], diff --git a/web/src/components/PlayView.tsx b/web/src/components/PlayView.tsx index 68e0c32..207276a 100644 --- a/web/src/components/PlayView.tsx +++ b/web/src/components/PlayView.tsx @@ -233,7 +233,7 @@ export function PlayView({ + ); +} + +export function RemoteAssignPopup({ + album, + currentTrackIndex, + mapping, + albums, + onAssign, + onClear, + onClose, +}: Props) { const byDigit = new Map(mapping?.slots.map((slot) => [slot.digit, slot])); + const songChoice = canAssignSong(album); + const currentTrack = album.tracks[currentTrackIndex]; + const [fromTrack, setFromTrack] = useState(false); + const assignsSong = songChoice && fromTrack; return (
@@ -39,9 +90,29 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o marginBottom: 16, }} > - „{album.title}" antippen, welche Taste? + „{assignsSong && currentTrack ? currentTrack.title : album.title}" – welche Taste?
+ {songChoice && ( +
+ setFromTrack(false)}> + Ganzes Album + + setFromTrack(true)}> + Ab diesem Lied{currentTrack ? `: ${currentTrack.title}` : ""} + +
+ )} +
{view.state !== "empty" && ( diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts index 91b60cf..18303bc 100644 --- a/web/src/lib/__tests__/keyboard.test.ts +++ b/web/src/lib/__tests__/keyboard.test.ts @@ -396,9 +396,11 @@ describe("selectionAlbumAt", () => { 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 arms assigning the album while something is playing, Shift+A the song", () => { + expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: "album" } }]); + expect(press("A", playing, { shiftKey: true })).toEqual([ + { type: "ui", patch: { assignPending: "track" } }, + ]); }); it("a plain a still types into search everywhere else", () => { @@ -411,22 +413,49 @@ describe("remote key assignment", () => { }); 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 } }, + expect(press("5", { ...playing, assignPending: "album" })).toEqual([ + { type: "assign", digit: "5", fromTrack: false }, + { type: "ui", patch: { assignPending: null } }, ]); - }); - - 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 } }, + expect(press("5", { ...playing, assignPending: "track" })).toEqual([ + { type: "assign", digit: "5", fromTrack: true }, + { type: "ui", patch: { assignPending: null } }, ]); }); it("ESC disarms before doing anything else", () => { - const armed: UiState = { ...playing, assignPending: true }; - expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: false } }]); + const armed: UiState = { ...playing, assignPending: "album" }; + expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: null } }]); + }); +}); + +describe("number keys", () => { + const remote = (digit: string) => [ + { type: "pop", freq: 420 }, + { type: "remote", digit }, + ]; + + it("press the IR remote's key on the root, browse, play and room screens", () => { + expect(press("5", initialUiState)).toEqual(remote("5")); + expect(press("0", { ...initialUiState, group: "music" })).toEqual(remote("0")); + expect(press("3", { ...initialUiState, view: "play" })).toEqual(remote("3")); + expect(press("7", { ...initialUiState, page: "room" })).toEqual(remote("7")); + }); + + it("type into the search once the search box is open", () => { + expect(press("5", { ...initialUiState, search: "folge " })).toEqual([ + { type: "ui", patch: { search: "folge 5", view: "browse", selIndex: 0 } }, + ]); + expect(press("5", { ...initialUiState, searchOpen: true })).toEqual([ + { type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } }, + ]); + expect(press("5", { ...initialUiState, mode: "tracks" })).toEqual([ + { type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } }, + ]); + }); + + it("are left alone on the typing tab", () => { + expect(press("5", { ...initialUiState, page: "typing" })).toEqual([]); }); }); diff --git a/web/src/lib/__tests__/remote.test.ts b/web/src/lib/__tests__/remote.test.ts index 6521548..cd8b868 100644 --- a/web/src/lib/__tests__/remote.test.ts +++ b/web/src/lib/__tests__/remote.test.ts @@ -31,6 +31,14 @@ describe("targetForAlbum", () => { }); }); + it("starts at one song when given its index", () => { + expect(targetForAlbum(album("a"), 3)).toEqual({ + target_kind: "album", + target: "a", + track_index: 3, + }); + }); + it("targets the show, not the episode, for a podcast", () => { const episode = album("ep1", { kind: "book", @@ -57,20 +65,36 @@ describe("remoteSlotView", () => { target_kind: "album", target: "resolved-id", resolved_album_id: "resolved-id", + track_index: 0, }; expect(remoteSlotView(slot, albums)).toEqual({ state: "ok", slot, album: albums[0], + track: null, }); }); + it("names the song when the slot starts at one", () => { + const track = { title: "Lied 2", duration: 60, analysis: null, locked: false, unlock_hint: null }; + const withTracks = [album("resolved-id", { tracks: [{ ...track, title: "Lied 1" }, track] })]; + const slot: RemoteSlot = { + digit: "3", + target_kind: "album", + target: "resolved-id", + resolved_album_id: "resolved-id", + track_index: 1, + }; + expect(remoteSlotView(slot, withTracks)).toMatchObject({ state: "ok", track }); + }); + 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, + track_index: 0, }; expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot }); }); @@ -81,6 +105,7 @@ describe("remoteSlotView", () => { target_kind: "album", target: "gone", resolved_album_id: "gone", + track_index: 0, }; expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot }); }); diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts index b9b0754..e3c96ce 100644 --- a/web/src/lib/keyboard.ts +++ b/web/src/lib/keyboard.ts @@ -36,9 +36,10 @@ export interface UiState { modalTrackIndex: number; 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; + /** Armed on the play screen: the next digit assigns what's playing to that remote key + * instead of doing whatever it would normally do. `a` arms "album" (the whole album + * or show), Shift+`A` arms "track" (the song playing now, then the rest of the album). */ + assignPending: "album" | "track" | null; } export const initialUiState: UiState = { @@ -55,7 +56,7 @@ export const initialUiState: UiState = { modalTrackIndex: 0, showHelp: false, cols: 4, - assignPending: false, + assignPending: null, }; export type Action = @@ -67,7 +68,9 @@ export type Action = | { type: "volume"; delta: number } | { type: "seek"; delta: number } | { type: "pop"; freq: number } - | { type: "assign"; digit: string } + | { type: "assign"; digit: string; fromTrack: boolean } + /** A number key, doing what the same key on the IR remote does. */ + | { type: "remote"; digit: string } | { type: "mute" } | { type: "screen" }; @@ -190,7 +193,7 @@ export function browseBackActions(state: UiState): Action[] { /** 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 } }]; + return [{ type: "ui", patch: { assignPending: null } }]; } if (state.showHelp || state.openAlbumId !== null) { return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }]; @@ -283,7 +286,17 @@ export function handleKey( // 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 } }]; + return [ + { type: "assign", digit: key, fromTrack: state.assignPending === "track" }, + { type: "ui", patch: { assignPending: null } }, + ]; + } + + // A number key does what the IR remote's does - from every page bar the typing tab + // (handled above), the room page included, since the remote works from there too. + // Mid-search a digit is part of the query instead ("Folge 12"). + if (/^[0-9]$/.test(key) && !event.ctrlKey && !event.metaKey && !searchBoxShown(state)) { + return [{ type: "pop", freq: 420 }, { type: "remote", digit: key }]; } // The room page has no browse hierarchy of its own - only ESC (handled below) and @@ -423,9 +436,10 @@ export function handleKey( 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. + // just the first letter of a search, like any other key. Shift picks the song + // playing now rather than the whole album. if (state.view === "play" && !state.showHelp && state.openAlbumId === null) { - return [{ type: "ui", patch: { assignPending: true } }]; + return [{ type: "ui", patch: { assignPending: event.shiftKey ? "track" : "album" } }]; } return typeIntoSearch(state, key); default: diff --git a/web/src/lib/remote.ts b/web/src/lib/remote.ts index 31ca3fa..de17a55 100644 --- a/web/src/lib/remote.ts +++ b/web/src/lib/remote.ts @@ -6,26 +6,36 @@ decides what a press actually assigns, and so a slot's display state - "nothing inside the component that renders it. */ -import type { Album, RemoteSlot, RemoteSlotInput } from "../api/types"; +import type { Album, RemoteSlot, RemoteSlotInput, Track } 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. + * to whatever is newest on every press, and `trackIndex` is ignored. Everything else + * targets the album itself, started from `trackIndex`: 0 for the whole album, or one + * song followed by the rest of it. */ -export function targetForAlbum(album: Album): RemoteSlotInput { +export function targetForAlbum(album: Album, trackIndex = 0): RemoteSlotInput { if (groupOf(album) === "podcasts" && album.series) { return { target_kind: "series", target: album.series }; } - return { target_kind: "album", target: album.id }; + return trackIndex > 0 + ? { target_kind: "album", target: album.id, track_index: trackIndex } + : { target_kind: "album", target: album.id }; +} + +/** Whether assigning one song of `album`, rather than all of it, means anything: not + * for a podcast (the show is assigned, not an episode) or an album of one track. */ +export function canAssignSong(album: Album): boolean { + return groupOf(album) !== "podcasts" && album.tracks.length > 1; } export type RemoteSlotView = | { state: "empty" } - | { state: "ok"; slot: RemoteSlot; album: Album } + /** `track` is the song the key starts at, or `null` for the whole album. */ + | { state: "ok"; slot: RemoteSlot; album: Album; track: Track | null } | { state: "missing"; slot: RemoteSlot }; /** How one digit's card should render, given the current mapping and the library. */ @@ -37,5 +47,7 @@ export function remoteSlotView( 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 }; + if (!album) return { state: "missing", slot }; + const track = slot.track_index > 0 ? (album.tracks[slot.track_index] ?? null) : null; + return { state: "ok", slot, album, track }; }