Number keys: the keyboard's digits work like the remote's; a key can hold one song
The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.
A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -217,6 +217,8 @@ copied to the device.
|
|||||||
| `POST /api/play` | `{album_id, track_index?}` |
|
| `POST /api/play` | `{album_id, track_index?}` |
|
||||||
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
|
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
|
||||||
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
|
| `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 |
|
| `GET` `PUT /api/settings` | parent mode |
|
||||||
|
|
||||||
Two decisions worth knowing:
|
Two decisions worth knowing:
|
||||||
|
|||||||
@@ -178,8 +178,10 @@ figures:
|
|||||||
# or any digit within it, for "unassigned" - a fresh install boots with none of this
|
# 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.
|
# 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_kind: album -> starts from the first track (music, audiobooks), or from
|
||||||
# target is an album id, as shown at GET /api/library.
|
# 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
|
# target_kind: series -> always plays the newest episode of a podcast show, resolved
|
||||||
# fresh on every press - never a fixed episode. target is the
|
# fresh on every press - never a fixed episode. target is the
|
||||||
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
||||||
@@ -190,3 +192,7 @@ remote:
|
|||||||
"2":
|
"2":
|
||||||
target_kind: series
|
target_kind: series
|
||||||
target: "Wissen macht Ah"
|
target: "Wissen macht Ah"
|
||||||
|
"3":
|
||||||
|
target_kind: album
|
||||||
|
target: "3f9a0c12ab44"
|
||||||
|
track_index: 4
|
||||||
|
|||||||
@@ -347,8 +347,8 @@ class FigureConfig(_Strict):
|
|||||||
class RemoteSlotConfig(_Strict):
|
class RemoteSlotConfig(_Strict):
|
||||||
"""What a number key on the IR remote plays.
|
"""What a number key on the IR remote plays.
|
||||||
|
|
||||||
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
|
``"album"``: ``target`` is an ``Album.id`` - a music album or an audiobook, started
|
||||||
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
|
from ``track_index``. ``"series"``: ``target`` is a podcast show name (an
|
||||||
``Album.series``); resolved to that show's newest episode fresh on every press,
|
``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
|
since a podcast show is not itself one playable thing in this library - each
|
||||||
episode is its own album.
|
episode is its own album.
|
||||||
@@ -356,6 +356,15 @@ class RemoteSlotConfig(_Strict):
|
|||||||
|
|
||||||
target_kind: Literal["album", "series"]
|
target_kind: Literal["album", "series"]
|
||||||
target: str
|
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):
|
class Config(_Strict):
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal, TypeAlias
|
from typing import TYPE_CHECKING, Literal, TypeAlias
|
||||||
|
|
||||||
from musicmouse.effects import LedEffect
|
from musicmouse.effects import LedEffect
|
||||||
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||||
from musicmouse.media import Playlist, Track
|
from musicmouse.media import Playlist, Track
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from musicmouse.config import Digit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ActiveFigureChanged",
|
"ActiveFigureChanged",
|
||||||
"ButtonEvent",
|
"ButtonEvent",
|
||||||
@@ -37,6 +40,7 @@ __all__ = [
|
|||||||
"PauseRequested",
|
"PauseRequested",
|
||||||
"PlayAlbumRequested",
|
"PlayAlbumRequested",
|
||||||
"PlayFigureRequested",
|
"PlayFigureRequested",
|
||||||
|
"PlayRemoteSlotRequested",
|
||||||
"PlayRequested",
|
"PlayRequested",
|
||||||
"PlaySeriesLatestRequested",
|
"PlaySeriesLatestRequested",
|
||||||
"PlaybackChanged",
|
"PlaybackChanged",
|
||||||
@@ -198,6 +202,17 @@ class PlaySeriesLatestRequested(IntentEvent):
|
|||||||
series: str
|
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)
|
@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.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from musicmouse.events import (
|
|||||||
PlayAlbumRequested,
|
PlayAlbumRequested,
|
||||||
PlayFigureRequested,
|
PlayFigureRequested,
|
||||||
PlaylistFinished,
|
PlaylistFinished,
|
||||||
|
PlayRemoteSlotRequested,
|
||||||
PlayRequested,
|
PlayRequested,
|
||||||
PlaySeriesLatestRequested,
|
PlaySeriesLatestRequested,
|
||||||
PreviousTrackRequested,
|
PreviousTrackRequested,
|
||||||
@@ -104,6 +105,27 @@ def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
|
|||||||
_start_album(app, album, 0)
|
_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)
|
@on(PlaylistFinished)
|
||||||
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||||
# Nothing was left half-played, so the next placement starts from the top.
|
# Nothing was left half-played, so the next placement starts from the top.
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ from musicmouse.events import (
|
|||||||
IntentEvent,
|
IntentEvent,
|
||||||
NextTrackRequested,
|
NextTrackRequested,
|
||||||
PauseRequested,
|
PauseRequested,
|
||||||
PlayAlbumRequested,
|
PlayRemoteSlotRequested,
|
||||||
PlayRequested,
|
PlayRequested,
|
||||||
PlaySeriesLatestRequested,
|
|
||||||
PreviousTrackRequested,
|
PreviousTrackRequested,
|
||||||
SetVolumeRequested,
|
SetVolumeRequested,
|
||||||
ToggleScreenRequested,
|
ToggleScreenRequested,
|
||||||
@@ -156,12 +155,4 @@ class LircService:
|
|||||||
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
|
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
|
||||||
|
|
||||||
def _play_digit(self, digit: Digit) -> None:
|
def _play_digit(self, digit: Digit) -> None:
|
||||||
slot = self.app.config.remote.get(digit)
|
self.app.bus.emit(PlayRemoteSlotRequested(digit=digit, source="lirc"))
|
||||||
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"))
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from musicmouse.events import (
|
|||||||
NextTrackRequested,
|
NextTrackRequested,
|
||||||
PauseRequested,
|
PauseRequested,
|
||||||
PlayAlbumRequested,
|
PlayAlbumRequested,
|
||||||
|
PlayRemoteSlotRequested,
|
||||||
PlayRequested,
|
PlayRequested,
|
||||||
PreviousTrackRequested,
|
PreviousTrackRequested,
|
||||||
SeekRequested,
|
SeekRequested,
|
||||||
@@ -290,6 +291,12 @@ def build_router(
|
|||||||
raise HTTPException(status_code=404, detail="lirc not configured")
|
raise HTTPException(status_code=404, detail="lirc not configured")
|
||||||
return LircConfigOut(connected=app.state.lirc_connected)
|
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")
|
@router.get("/remote/mapping")
|
||||||
def get_remote_mapping() -> RemoteMappingOut:
|
def get_remote_mapping() -> RemoteMappingOut:
|
||||||
return read_mapping(app.config.remote, app.library)
|
return read_mapping(app.config.remote, app.library)
|
||||||
@@ -308,7 +315,19 @@ def build_router(
|
|||||||
status_code=422,
|
status_code=422,
|
||||||
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
|
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
|
app.config.remote = resolved
|
||||||
await asyncio.to_thread(write_mapping, config_path, resolved)
|
await asyncio.to_thread(write_mapping, config_path, resolved)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
|
|||||||
target_kind=slot.target_kind,
|
target_kind=slot.target_kind,
|
||||||
target=slot.target,
|
target=slot.target,
|
||||||
resolved_album_id=_resolve(slot, library),
|
resolved_album_id=_resolve(slot, library),
|
||||||
|
track_index=slot.track_index,
|
||||||
)
|
)
|
||||||
for digit, slot in sorted(remote.items())
|
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:
|
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
|
||||||
document = load_document(path)
|
document = load_document(path)
|
||||||
remote = {
|
remote: dict[str, dict[str, str | int]] = {}
|
||||||
digit: {"target_kind": slot.target_kind, "target": slot.target}
|
for digit, slot in mapping.items():
|
||||||
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
|
document["remote"] = remote
|
||||||
atomic_write(path, document)
|
atomic_write(path, document)
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ class RemoteSlotOut(BaseModel):
|
|||||||
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
|
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
|
||||||
#: broken-assignment state instead of silently dropping it.
|
#: broken-assignment state instead of silently dropping it.
|
||||||
resolved_album_id: str | None
|
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):
|
class RemoteMappingOut(BaseModel):
|
||||||
@@ -290,6 +292,7 @@ class RemoteMappingOut(BaseModel):
|
|||||||
class RemoteSlotIn(BaseModel):
|
class RemoteSlotIn(BaseModel):
|
||||||
target_kind: Literal["album", "series"]
|
target_kind: Literal["album", "series"]
|
||||||
target: str
|
target: str
|
||||||
|
track_index: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class RemoteMappingIn(BaseModel):
|
class RemoteMappingIn(BaseModel):
|
||||||
|
|||||||
@@ -96,6 +96,24 @@ def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None
|
|||||||
assert "remote" in message
|
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:
|
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
||||||
data = _config(
|
data = _config(
|
||||||
ha={
|
ha={
|
||||||
|
|||||||
@@ -383,6 +383,29 @@ async def test_an_album_digit_plays_from_the_beginning(
|
|||||||
assert sim.player.track_index == 0
|
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(
|
async def test_a_series_digit_plays_the_latest_episode(
|
||||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
import httpx2
|
import httpx2
|
||||||
import pytest
|
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.services.web.service import build_app
|
||||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
from tests.conftest import VALID_CONFIG, write_config
|
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
|
assert response.status_code == 200
|
||||||
slots = response.json()["slots"]
|
slots = response.json()["slots"]
|
||||||
assert 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")
|
text = (config_dir / "config.yml").read_text(encoding="utf-8")
|
||||||
assert "# The MusicMouse config." in text # comments survive
|
assert "# The MusicMouse config." in text # comments survive
|
||||||
reloaded = load_config(config_dir / "config.yml")
|
reloaded = load_config(config_dir / "config.yml")
|
||||||
assert reloaded.remote["3"].target == target
|
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(
|
async def test_saving_a_series_slot_resolves_to_the_latest_episode(
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
|||||||
import type { Action, UiState } from "./lib/keyboard";
|
import type { Action, UiState } from "./lib/keyboard";
|
||||||
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
|
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
|
||||||
import { playPop } from "./lib/pop";
|
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 type { Group, Mode, Results, SongHit } from "./lib/search";
|
||||||
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
||||||
import { SKIN } from "./lib/skins";
|
import { SKIN } from "./lib/skins";
|
||||||
@@ -228,7 +228,7 @@ export function App() {
|
|||||||
const slots: Record<string, RemoteSlotInput> = Object.fromEntries(
|
const slots: Record<string, RemoteSlotInput> = Object.fromEntries(
|
||||||
(remoteMapping?.slots ?? []).map((slot) => [
|
(remoteMapping?.slots ?? []).map((slot) => [
|
||||||
slot.digit,
|
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);
|
mutate(slots);
|
||||||
@@ -240,20 +240,35 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const assignCurrentTo = useCallback(
|
const assignCurrentTo = useCallback(
|
||||||
async (digit: string) => {
|
async (digit: string, fromTrack: boolean) => {
|
||||||
if (!currentAlbum) return;
|
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 {
|
try {
|
||||||
await saveMapping((slots) => {
|
await saveMapping((slots) => {
|
||||||
slots[digit] = target;
|
slots[digit] = target;
|
||||||
});
|
});
|
||||||
setAssignStatus(`Taste ${digit}: „${currentAlbum.title}"`);
|
setAssignStatus(`Taste ${digit}: „${songTitle ?? currentAlbum.title}"`);
|
||||||
} catch {
|
} catch {
|
||||||
setAssignStatus("Konnte nicht zugewiesen werden.");
|
setAssignStatus("Konnte nicht zugewiesen werden.");
|
||||||
}
|
}
|
||||||
setAssignPopupOpen(false);
|
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(
|
const clearSlot = useCallback(
|
||||||
@@ -279,7 +294,10 @@ export function App() {
|
|||||||
toggle();
|
toggle();
|
||||||
break;
|
break;
|
||||||
case "assign":
|
case "assign":
|
||||||
void assignCurrentTo(action.digit);
|
void assignCurrentTo(action.digit, action.fromTrack);
|
||||||
|
break;
|
||||||
|
case "remote":
|
||||||
|
pressRemote(action.digit);
|
||||||
break;
|
break;
|
||||||
case "next":
|
case "next":
|
||||||
playPop(260);
|
playPop(260);
|
||||||
@@ -330,6 +348,7 @@ export function App() {
|
|||||||
currentAlbum,
|
currentAlbum,
|
||||||
onMute,
|
onMute,
|
||||||
play,
|
play,
|
||||||
|
pressRemote,
|
||||||
seekTo,
|
seekTo,
|
||||||
setVolume,
|
setVolume,
|
||||||
skipPodcast,
|
skipPodcast,
|
||||||
@@ -374,7 +393,7 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ui.assignPending) return;
|
if (!ui.assignPending) return;
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(
|
||||||
() => setUi((previous) => ({ ...previous, assignPending: false })),
|
() => setUi((previous) => ({ ...previous, assignPending: null })),
|
||||||
ASSIGN_PENDING_TIMEOUT_MS,
|
ASSIGN_PENDING_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@@ -536,7 +555,7 @@ export function App() {
|
|||||||
onMute={onMute}
|
onMute={onMute}
|
||||||
onOpenAlbum={onOpenCurrentAlbum}
|
onOpenAlbum={onOpenCurrentAlbum}
|
||||||
onOpenAssign={() => setAssignPopupOpen(true)}
|
onOpenAssign={() => setAssignPopupOpen(true)}
|
||||||
assignPending={ui.assignPending}
|
assignPending={ui.assignPending !== null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -545,7 +564,8 @@ export function App() {
|
|||||||
album={currentAlbum}
|
album={currentAlbum}
|
||||||
mapping={remoteMapping}
|
mapping={remoteMapping}
|
||||||
albums={library.albums}
|
albums={library.albums}
|
||||||
onAssign={(digit) => void assignCurrentTo(digit)}
|
currentTrackIndex={state?.track_index ?? 0}
|
||||||
|
onAssign={(digit, fromTrack) => void assignCurrentTo(digit, fromTrack)}
|
||||||
onClear={clearSlot}
|
onClear={clearSlot}
|
||||||
onClose={() => setAssignPopupOpen(false)}
|
onClose={() => setAssignPopupOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export const api = {
|
|||||||
trackDetail: fetchTrackDetail,
|
trackDetail: fetchTrackDetail,
|
||||||
|
|
||||||
lircConfig: fetchLircConfig,
|
lircConfig: fetchLircConfig,
|
||||||
|
pressRemote: (digit: string) => post(`/remote/press/${digit}`),
|
||||||
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
|
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
|
||||||
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
|
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
|
||||||
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
|
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
|
||||||
|
|||||||
@@ -127,8 +127,9 @@ export interface HaEntityState {
|
|||||||
|
|
||||||
export type RemoteTargetKind = "album" | "series";
|
export type RemoteTargetKind = "album" | "series";
|
||||||
|
|
||||||
/** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, always
|
/** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, started
|
||||||
* started from track 0. "series": `target` is a podcast show name (`Album.series`),
|
* 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
|
* 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
|
* what it plays *right now* - `null` when the target no longer resolves (a moved
|
||||||
* album, an unknown show). Mirrors `RemoteSlotOut`. */
|
* album, an unknown show). Mirrors `RemoteSlotOut`. */
|
||||||
@@ -137,6 +138,7 @@ export interface RemoteSlot {
|
|||||||
target_kind: RemoteTargetKind;
|
target_kind: RemoteTargetKind;
|
||||||
target: string;
|
target: string;
|
||||||
resolved_album_id: string | null;
|
resolved_album_id: string | null;
|
||||||
|
track_index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteMapping {
|
export interface RemoteMapping {
|
||||||
@@ -146,6 +148,7 @@ export interface RemoteMapping {
|
|||||||
export interface RemoteSlotInput {
|
export interface RemoteSlotInput {
|
||||||
target_kind: RemoteTargetKind;
|
target_kind: RemoteTargetKind;
|
||||||
target: string;
|
target: string;
|
||||||
|
track_index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */
|
/** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string
|
|||||||
[["⇧J", "⇧K"], "Leiser / lauter"],
|
[["⇧J", "⇧K"], "Leiser / lauter"],
|
||||||
[["⇧M"], "Stumm"],
|
[["⇧M"], "Stumm"],
|
||||||
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
|
[["⇧←", "⇧→"], "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",
|
"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"],
|
[["ENTF"], "Bildschirm aus / an"],
|
||||||
[["F1"], "Diese Hilfe"],
|
[["F1"], "Diese Hilfe"],
|
||||||
[["ESC"], "Schließen / Suche löschen"],
|
[["ESC"], "Schließen / Suche löschen"],
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export function PlayView({
|
|||||||
<button
|
<button
|
||||||
onClick={onOpenAssign}
|
onClick={onOpenAssign}
|
||||||
aria-label={assignPending ? "Zahl drücken …" : "Taste zuweisen"}
|
aria-label={assignPending ? "Zahl drücken …" : "Taste zuweisen"}
|
||||||
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl)"
|
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl, Shift+A für nur dieses Lied)"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 28,
|
bottom: 28,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/** "Which remote key?" - a simple 0-9 grid for assigning the currently playing album
|
/** "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
|
* or podcast show to a number key on the IR remote. Same job as pressing "A" (or
|
||||||
* digit; this is the tap-only way in, for a viewer with no keyboard. */
|
* Shift+"A" for the song) then a digit; this is the tap-only way in, for a viewer with
|
||||||
|
* no keyboard. */
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { Album, RemoteMapping } from "../api/types";
|
import type { Album, RemoteMapping } from "../api/types";
|
||||||
import { remoteSlotView } from "../lib/remote";
|
import { canAssignSong, remoteSlotView } from "../lib/remote";
|
||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
|
|
||||||
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
|
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
|
||||||
@@ -11,15 +14,63 @@ const ZERO_COLUMN = 2;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
album: Album;
|
album: Album;
|
||||||
|
/** The track playing now - what "ab diesem Lied" assigns. */
|
||||||
|
currentTrackIndex: number;
|
||||||
mapping: RemoteMapping | null;
|
mapping: RemoteMapping | null;
|
||||||
albums: Album[];
|
albums: Album[];
|
||||||
onAssign: (digit: string) => void;
|
onAssign: (digit: string, fromTrack: boolean) => void;
|
||||||
onClear: (digit: string) => void;
|
onClear: (digit: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, onClose }: Props) {
|
function ChoiceButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 800,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
background: active ? "var(--accent)" : "transparent",
|
||||||
|
color: active ? "#fff" : "var(--ink)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoteAssignPopup({
|
||||||
|
album,
|
||||||
|
currentTrackIndex,
|
||||||
|
mapping,
|
||||||
|
albums,
|
||||||
|
onAssign,
|
||||||
|
onClear,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
const byDigit = new Map(mapping?.slots.map((slot) => [slot.digit, slot]));
|
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 (
|
return (
|
||||||
<div className="overlay" style={{ zIndex: 6 }} onClick={onClose}>
|
<div className="overlay" style={{ zIndex: 6 }} onClick={onClose}>
|
||||||
@@ -39,9 +90,29 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
|||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
„{album.title}" antippen, welche Taste?
|
„{assignsSong && currentTrack ? currentTrack.title : album.title}" – welche Taste?
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{songChoice && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 4,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
background: "oklch(94% 0.01 var(--hue))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChoiceButton active={!fromTrack} onClick={() => setFromTrack(false)}>
|
||||||
|
Ganzes Album
|
||||||
|
</ChoiceButton>
|
||||||
|
<ChoiceButton active={fromTrack} onClick={() => setFromTrack(true)}>
|
||||||
|
Ab diesem Lied{currentTrack ? `: ${currentTrack.title}` : ""}
|
||||||
|
</ChoiceButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -60,10 +131,10 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAssign(digit)}
|
onClick={() => onAssign(digit, assignsSong)}
|
||||||
title={
|
title={
|
||||||
view.state === "ok"
|
view.state === "ok"
|
||||||
? `Taste ${digit}: ${view.album.title} → überschreiben`
|
? `Taste ${digit}: ${view.track?.title ?? view.album.title} → überschreiben`
|
||||||
: `Taste ${digit} zuweisen`
|
: `Taste ${digit} zuweisen`
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
@@ -98,6 +169,11 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
|||||||
)}
|
)}
|
||||||
<span style={{ fontSize: 13, fontWeight: 900, color: "var(--ink)" }}>
|
<span style={{ fontSize: 13, fontWeight: 900, color: "var(--ink)" }}>
|
||||||
{digit}
|
{digit}
|
||||||
|
{view.state === "ok" && view.track && (
|
||||||
|
<span style={{ fontWeight: 700, opacity: 0.7 }}>
|
||||||
|
{" "}· Lied {view.slot.track_index + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{view.state !== "empty" && (
|
{view.state !== "empty" && (
|
||||||
|
|||||||
@@ -396,9 +396,11 @@ describe("selectionAlbumAt", () => {
|
|||||||
describe("remote key assignment", () => {
|
describe("remote key assignment", () => {
|
||||||
const playing: UiState = { ...initialUiState, view: "play" };
|
const playing: UiState = { ...initialUiState, view: "play" };
|
||||||
|
|
||||||
it("A arms assignment while something is playing", () => {
|
it("A arms assigning the album while something is playing, Shift+A the song", () => {
|
||||||
expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
|
expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: "album" } }]);
|
||||||
expect(press("A", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
|
expect(press("A", playing, { shiftKey: true })).toEqual([
|
||||||
|
{ type: "ui", patch: { assignPending: "track" } },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a plain a still types into search everywhere else", () => {
|
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", () => {
|
it("a following digit assigns and disarms", () => {
|
||||||
const armed: UiState = { ...playing, assignPending: true };
|
expect(press("5", { ...playing, assignPending: "album" })).toEqual([
|
||||||
expect(press("5", armed)).toEqual([
|
{ type: "assign", digit: "5", fromTrack: false },
|
||||||
{ type: "assign", digit: "5" },
|
{ type: "ui", patch: { assignPending: null } },
|
||||||
{ type: "ui", patch: { assignPending: false } },
|
|
||||||
]);
|
]);
|
||||||
});
|
expect(press("5", { ...playing, assignPending: "track" })).toEqual([
|
||||||
|
{ type: "assign", digit: "5", fromTrack: true },
|
||||||
it("a digit does the normal thing when nothing is armed", () => {
|
{ type: "ui", patch: { assignPending: null } },
|
||||||
expect(press("5", initialUiState)).toEqual([
|
|
||||||
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ESC disarms before doing anything else", () => {
|
it("ESC disarms before doing anything else", () => {
|
||||||
const armed: UiState = { ...playing, assignPending: true };
|
const armed: UiState = { ...playing, assignPending: "album" };
|
||||||
expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: false } }]);
|
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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
it("targets the show, not the episode, for a podcast", () => {
|
||||||
const episode = album("ep1", {
|
const episode = album("ep1", {
|
||||||
kind: "book",
|
kind: "book",
|
||||||
@@ -57,20 +65,36 @@ describe("remoteSlotView", () => {
|
|||||||
target_kind: "album",
|
target_kind: "album",
|
||||||
target: "resolved-id",
|
target: "resolved-id",
|
||||||
resolved_album_id: "resolved-id",
|
resolved_album_id: "resolved-id",
|
||||||
|
track_index: 0,
|
||||||
};
|
};
|
||||||
expect(remoteSlotView(slot, albums)).toEqual({
|
expect(remoteSlotView(slot, albums)).toEqual({
|
||||||
state: "ok",
|
state: "ok",
|
||||||
slot,
|
slot,
|
||||||
album: albums[0],
|
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", () => {
|
it("is missing when the backend could not resolve the target", () => {
|
||||||
const slot: RemoteSlot = {
|
const slot: RemoteSlot = {
|
||||||
digit: "4",
|
digit: "4",
|
||||||
target_kind: "series",
|
target_kind: "series",
|
||||||
target: "no such show",
|
target: "no such show",
|
||||||
resolved_album_id: null,
|
resolved_album_id: null,
|
||||||
|
track_index: 0,
|
||||||
};
|
};
|
||||||
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
||||||
});
|
});
|
||||||
@@ -81,6 +105,7 @@ describe("remoteSlotView", () => {
|
|||||||
target_kind: "album",
|
target_kind: "album",
|
||||||
target: "gone",
|
target: "gone",
|
||||||
resolved_album_id: "gone",
|
resolved_album_id: "gone",
|
||||||
|
track_index: 0,
|
||||||
};
|
};
|
||||||
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ export interface UiState {
|
|||||||
modalTrackIndex: number;
|
modalTrackIndex: number;
|
||||||
showHelp: boolean;
|
showHelp: boolean;
|
||||||
cols: number;
|
cols: number;
|
||||||
/** Armed by `A` on the play screen: the next digit assigns what's playing to that
|
/** Armed on the play screen: the next digit assigns what's playing to that remote key
|
||||||
* remote key instead of doing whatever it would normally do. */
|
* instead of doing whatever it would normally do. `a` arms "album" (the whole album
|
||||||
assignPending: boolean;
|
* or show), Shift+`A` arms "track" (the song playing now, then the rest of the album). */
|
||||||
|
assignPending: "album" | "track" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialUiState: UiState = {
|
export const initialUiState: UiState = {
|
||||||
@@ -55,7 +56,7 @@ export const initialUiState: UiState = {
|
|||||||
modalTrackIndex: 0,
|
modalTrackIndex: 0,
|
||||||
showHelp: false,
|
showHelp: false,
|
||||||
cols: 4,
|
cols: 4,
|
||||||
assignPending: false,
|
assignPending: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
@@ -67,7 +68,9 @@ export type Action =
|
|||||||
| { 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 }
|
| { 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: "mute" }
|
||||||
| { type: "screen" };
|
| { 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. */
|
/** 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) {
|
if (state.assignPending) {
|
||||||
return [{ type: "ui", patch: { assignPending: false } }];
|
return [{ type: "ui", patch: { assignPending: null } }];
|
||||||
}
|
}
|
||||||
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 } }];
|
||||||
@@ -283,7 +286,17 @@ export function handleKey(
|
|||||||
// Armed by "A" below; the next digit assigns what's playing to that remote key
|
// 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, ...).
|
// instead of whatever it would normally do (typing into search, seeking, ...).
|
||||||
if (state.assignPending && /^[0-9]$/.test(key)) {
|
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
|
// 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":
|
||||||
case "A":
|
case "A":
|
||||||
// Only while something is loaded on the play screen - everywhere else "a" is
|
// 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) {
|
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);
|
return typeIntoSearch(state, key);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -6,26 +6,36 @@ decides what a press actually assigns, and so a slot's display state - "nothing
|
|||||||
inside the component that renders it.
|
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";
|
import { groupOf } from "./search";
|
||||||
|
|
||||||
/** What pressing "assign" on `album` actually stores.
|
/** What pressing "assign" on `album` actually stores.
|
||||||
*
|
*
|
||||||
* A podcast is not itself one playable thing in this library - each episode is its
|
* 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
|
* 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,
|
* to whatever is newest on every press, and `trackIndex` is ignored. Everything else
|
||||||
* always started from track 0.
|
* 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) {
|
if (groupOf(album) === "podcasts" && album.series) {
|
||||||
return { target_kind: "series", target: 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 =
|
export type RemoteSlotView =
|
||||||
| { state: "empty" }
|
| { 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 };
|
| { state: "missing"; slot: RemoteSlot };
|
||||||
|
|
||||||
/** How one digit's card should render, given the current mapping and the library. */
|
/** 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
|
const album = slot.resolved_album_id
|
||||||
? albums.find((candidate) => candidate.id === slot.resolved_album_id)
|
? albums.find((candidate) => candidate.id === slot.resolved_album_id)
|
||||||
: undefined;
|
: 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user