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:
2026-09-11 08:31:28 +02:00
parent 57afc32f4a
commit 747c390303
32 changed files with 1603 additions and 33 deletions

View File

@@ -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);
}
}