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:
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user