Add IR remote control (LIRC) with a number-key content mapping
Adds a TCP client for lircd's classic protocol: play/pause/next/prev/ volume/mute map to the same intents every other front-end already emits, and number keys 0-9 play an assigned album/audiobook from the start or a podcast show's newest episode, resolved fresh on every press. The mapping is configured in config.yml and editable from the frontend: a small "Taste zuweisen" button on the play screen (or the A+digit keyboard shortcut) opens a 10-key picker to assign whatever is currently playing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -231,6 +231,43 @@ describe("keyboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote key assignment", () => {
|
||||
const playing: UiState = { ...initialUiState, view: "play" };
|
||||
|
||||
it("A arms assignment while something is playing", () => {
|
||||
expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
|
||||
expect(press("A", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
|
||||
});
|
||||
|
||||
it("a plain a still types into search everywhere else", () => {
|
||||
expect(press("a", initialUiState)).toEqual([
|
||||
{ type: "ui", patch: { search: "a", view: "browse", selIndex: 0 } },
|
||||
]);
|
||||
expect(
|
||||
press("a", { ...initialUiState, view: "play", openAlbumId: "b" }),
|
||||
).toEqual([{ type: "ui", patch: { search: "a", view: "browse", selIndex: 0 } }]);
|
||||
});
|
||||
|
||||
it("a following digit assigns and disarms", () => {
|
||||
const armed: UiState = { ...playing, assignPending: true };
|
||||
expect(press("5", armed)).toEqual([
|
||||
{ type: "assign", digit: "5" },
|
||||
{ type: "ui", patch: { assignPending: false } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a digit does the normal thing when nothing is armed", () => {
|
||||
expect(press("5", initialUiState)).toEqual([
|
||||
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ESC disarms before doing anything else", () => {
|
||||
const armed: UiState = { ...playing, assignPending: true };
|
||||
expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: false } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectionAt", () => {
|
||||
it("walks songs, then categories, then albums in one flat index space", () => {
|
||||
const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" };
|
||||
|
||||
86
web/src/lib/__tests__/remote.test.ts
Normal file
86
web/src/lib/__tests__/remote.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Album, RemoteSlot } from "../../api/types";
|
||||
import { remoteSlotView, targetForAlbum } from "../remote";
|
||||
|
||||
function album(id: string, over: Partial<Album> = {}): Album {
|
||||
return {
|
||||
id,
|
||||
section: "Musik",
|
||||
kind: "music",
|
||||
title: `Album ${id}`,
|
||||
artist: "Kinderparty",
|
||||
series: null,
|
||||
figure: null,
|
||||
category: "Kinderparty",
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
tracks: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("targetForAlbum", () => {
|
||||
it("targets the album itself for music and audiobooks", () => {
|
||||
expect(targetForAlbum(album("a"))).toEqual({ target_kind: "album", target: "a" });
|
||||
expect(targetForAlbum(album("b", { kind: "book", series: "Conni" }))).toEqual({
|
||||
target_kind: "album",
|
||||
target: "b",
|
||||
});
|
||||
});
|
||||
|
||||
it("targets the show, not the episode, for a podcast", () => {
|
||||
const episode = album("ep1", {
|
||||
kind: "book",
|
||||
section: "Kinderpodcasts",
|
||||
series: "Wissen macht Ah",
|
||||
});
|
||||
expect(targetForAlbum(episode)).toEqual({
|
||||
target_kind: "series",
|
||||
target: "Wissen macht Ah",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("remoteSlotView", () => {
|
||||
const albums = [album("resolved-id")];
|
||||
|
||||
it("is empty when nothing is assigned", () => {
|
||||
expect(remoteSlotView(undefined, albums)).toEqual({ state: "empty" });
|
||||
});
|
||||
|
||||
it("is ok when the slot resolves to a known album", () => {
|
||||
const slot: RemoteSlot = {
|
||||
digit: "3",
|
||||
target_kind: "album",
|
||||
target: "resolved-id",
|
||||
resolved_album_id: "resolved-id",
|
||||
};
|
||||
expect(remoteSlotView(slot, albums)).toEqual({
|
||||
state: "ok",
|
||||
slot,
|
||||
album: albums[0],
|
||||
});
|
||||
});
|
||||
|
||||
it("is missing when the backend could not resolve the target", () => {
|
||||
const slot: RemoteSlot = {
|
||||
digit: "4",
|
||||
target_kind: "series",
|
||||
target: "no such show",
|
||||
resolved_album_id: null,
|
||||
};
|
||||
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
||||
});
|
||||
|
||||
it("is missing when the resolved album has since left the library", () => {
|
||||
const slot: RemoteSlot = {
|
||||
digit: "5",
|
||||
target_kind: "album",
|
||||
target: "gone",
|
||||
resolved_album_id: "gone",
|
||||
};
|
||||
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,9 @@ export interface UiState {
|
||||
openAlbumId: string | null;
|
||||
showHelp: boolean;
|
||||
cols: number;
|
||||
/** Armed by `A` on the play screen: the next digit assigns what's playing to that
|
||||
* remote key instead of doing whatever it would normally do. */
|
||||
assignPending: boolean;
|
||||
}
|
||||
|
||||
export const initialUiState: UiState = {
|
||||
@@ -35,6 +38,7 @@ export const initialUiState: UiState = {
|
||||
openAlbumId: null,
|
||||
showHelp: false,
|
||||
cols: 4,
|
||||
assignPending: false,
|
||||
};
|
||||
|
||||
export type Action =
|
||||
@@ -45,11 +49,20 @@ export type Action =
|
||||
| { type: "previous" }
|
||||
| { type: "volume"; delta: number }
|
||||
| { type: "seek"; delta: number }
|
||||
| { type: "pop"; freq: number };
|
||||
| { type: "pop"; freq: number }
|
||||
| { type: "assign"; digit: string };
|
||||
|
||||
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
||||
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
||||
|
||||
/** The default behaviour for a plain character key: type it into search. Shared by
|
||||
* `default` and by `a`/`A`, which only sometimes means something else. */
|
||||
function typeIntoSearch(state: UiState, key: string): Action[] {
|
||||
return SEARCHABLE.test(key)
|
||||
? [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }]
|
||||
: [];
|
||||
}
|
||||
|
||||
const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
|
||||
|
||||
export const VOLUME_STEP = 10;
|
||||
@@ -89,6 +102,9 @@ function moveSelection(state: UiState, results: Results, dx: number, dy: number)
|
||||
|
||||
/** ESC peels one layer off at a time rather than dumping you back at the top. */
|
||||
function escape(state: UiState): Action[] {
|
||||
if (state.assignPending) {
|
||||
return [{ type: "ui", patch: { assignPending: false } }];
|
||||
}
|
||||
if (state.showHelp || state.openAlbumId !== null) {
|
||||
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
||||
}
|
||||
@@ -135,6 +151,12 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
|
||||
}
|
||||
}
|
||||
|
||||
// Armed by "A" below; the next digit assigns what's playing to that remote key
|
||||
// instead of whatever it would normally do (typing into search, seeking, ...).
|
||||
if (state.assignPending && /^[0-9]$/.test(key)) {
|
||||
return [{ type: "assign", digit: key }, { type: "ui", patch: { assignPending: false } }];
|
||||
}
|
||||
|
||||
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
|
||||
|
||||
switch (key) {
|
||||
@@ -201,10 +223,15 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
|
||||
const chosen = selectionAt(results, state.selIndex);
|
||||
return chosen ? [chosen] : [];
|
||||
}
|
||||
default:
|
||||
if (SEARCHABLE.test(key)) {
|
||||
return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }];
|
||||
case "a":
|
||||
case "A":
|
||||
// Only while something is loaded on the play screen - everywhere else "a" is
|
||||
// just the first letter of a search, like any other key.
|
||||
if (state.view === "play" && !state.showHelp && state.openAlbumId === null) {
|
||||
return [{ type: "ui", patch: { assignPending: true } }];
|
||||
}
|
||||
return [];
|
||||
return typeIntoSearch(state, key);
|
||||
default:
|
||||
return typeIntoSearch(state, key);
|
||||
}
|
||||
}
|
||||
|
||||
41
web/src/lib/remote.ts
Normal file
41
web/src/lib/remote.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** Assigning the currently playing album to a number key on the IR remote.
|
||||
|
||||
Kept pure so the popup and the `A` + digit keyboard shortcut can share one place that
|
||||
decides what a press actually assigns, and so a slot's display state - "nothing here",
|
||||
"here's what plays", "this used to point somewhere real" - needs no branching logic
|
||||
inside the component that renders it.
|
||||
*/
|
||||
|
||||
import type { Album, RemoteSlot, RemoteSlotInput } from "../api/types";
|
||||
import { groupOf } from "./search";
|
||||
|
||||
/** What pressing "assign" on `album` actually stores.
|
||||
*
|
||||
* A podcast is not itself one playable thing in this library - each episode is its
|
||||
* own album - so a podcast assignment targets the *show* (its series name), resolved
|
||||
* to whatever is newest on every press. Everything else targets the album itself,
|
||||
* always started from track 0.
|
||||
*/
|
||||
export function targetForAlbum(album: Album): RemoteSlotInput {
|
||||
if (groupOf(album) === "podcasts" && album.series) {
|
||||
return { target_kind: "series", target: album.series };
|
||||
}
|
||||
return { target_kind: "album", target: album.id };
|
||||
}
|
||||
|
||||
export type RemoteSlotView =
|
||||
| { state: "empty" }
|
||||
| { state: "ok"; slot: RemoteSlot; album: Album }
|
||||
| { state: "missing"; slot: RemoteSlot };
|
||||
|
||||
/** How one digit's card should render, given the current mapping and the library. */
|
||||
export function remoteSlotView(
|
||||
slot: RemoteSlot | undefined,
|
||||
albums: Album[],
|
||||
): RemoteSlotView {
|
||||
if (!slot) return { state: "empty" };
|
||||
const album = slot.resolved_album_id
|
||||
? albums.find((candidate) => candidate.id === slot.resolved_album_id)
|
||||
: undefined;
|
||||
return album ? { state: "ok", slot, album } : { state: "missing", slot };
|
||||
}
|
||||
Reference in New Issue
Block a user