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

@@ -9,7 +9,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "./api/client";
import type { Album, HaConfig } from "./api/types";
import type { Album, HaConfig, RemoteMapping, RemoteSlotInput } from "./api/types";
import { AlbumModal } from "./components/AlbumModal";
import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience";
import { AmbienceDebugOverlay } from "./components/AmbienceDebugOverlay";
@@ -19,6 +19,7 @@ import { HelpOverlay } from "./components/HelpOverlay";
import { ParentPanel } from "./components/ParentPanel";
import { PlayerBar } from "./components/PlayerBar";
import { PlayView } from "./components/PlayView";
import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
import { RoomView } from "./components/RoomView";
import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary";
@@ -28,9 +29,16 @@ import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
import type { Action, UiState } from "./lib/keyboard";
import { handleKey, initialUiState } from "./lib/keyboard";
import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote";
import type { Group, Results, SongHit } from "./lib/search";
import { groupOf, results as computeResults } from "./lib/search";
/** How long an armed "A" waits for the digit that completes the shortcut. */
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
/** How long a "taste zugewiesen" confirmation stays on screen. */
const ASSIGN_STATUS_TIMEOUT_MS = 2500;
/** Volume when un-muting, matching the mockup. */
const UNMUTE_PERCENT = 60;
@@ -63,6 +71,17 @@ export function App() {
void api.haConfig().then(setHaConfig);
}, []);
// The remote key -> album/show mapping. Always fetched (unlike haConfig, this
// endpoint has no "not configured" state - it's just empty on a fresh install) so
// the assign popup and the keyboard shortcut always have something to read/merge.
const [remoteMapping, setRemoteMapping] = useState<RemoteMapping | null>(null);
const [assignPopupOpen, setAssignPopupOpen] = useState(false);
const [assignStatus, setAssignStatus] = useState<string | null>(null);
useEffect(() => {
void api.remoteMapping().then(setRemoteMapping);
}, []);
const state = connection.state;
useEffect(() => {
@@ -117,6 +136,50 @@ export function App() {
}, [connection, library.albums, play, results.albums, state]);
/** The one place a keyboard action, a click or a tap all end up. */
// Read-modify-write the whole mapping, like ParentPanel does for settings: the
// backend takes a full replacement, not a per-slot patch.
const saveMapping = useCallback(
async (mutate: (slots: Record<string, RemoteSlotInput>) => void) => {
const slots: Record<string, RemoteSlotInput> = Object.fromEntries(
(remoteMapping?.slots ?? []).map((slot) => [
slot.digit,
{ target_kind: slot.target_kind, target: slot.target },
]),
);
mutate(slots);
const updated = await api.saveRemoteMapping(slots);
setRemoteMapping(updated);
return updated;
},
[remoteMapping],
);
const assignCurrentTo = useCallback(
async (digit: string) => {
if (!currentAlbum) return;
const target = targetForAlbum(currentAlbum);
try {
await saveMapping((slots) => {
slots[digit] = target;
});
setAssignStatus(`Taste ${digit}: „${currentAlbum.title}"`);
} catch {
setAssignStatus("Konnte nicht zugewiesen werden.");
}
setAssignPopupOpen(false);
},
[currentAlbum, saveMapping],
);
const clearSlot = useCallback(
(digit: string) => {
void saveMapping((slots) => {
delete slots[digit];
});
},
[saveMapping],
);
const run = useCallback(
(actions: Action[]) => {
for (const action of actions) {
@@ -130,6 +193,9 @@ export function App() {
case "toggle":
toggle();
break;
case "assign":
void assignCurrentTo(action.digit);
break;
case "next":
playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
@@ -175,7 +241,7 @@ export function App() {
}
}
},
[connection, currentAlbum, play, setVolume, state, toggle],
[assignCurrentTo, connection, currentAlbum, play, setVolume, state, toggle],
);
// Held in a ref so the listener is installed once rather than on every state change.
@@ -198,6 +264,22 @@ export function App() {
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// An abandoned "A" press disarms itself rather than leaving the app half-primed.
useEffect(() => {
if (!ui.assignPending) return;
const timer = setTimeout(
() => setUi((previous) => ({ ...previous, assignPending: false })),
ASSIGN_PENDING_TIMEOUT_MS,
);
return () => clearTimeout(timer);
}, [ui.assignPending]);
useEffect(() => {
if (!assignStatus) return;
const timer = setTimeout(() => setAssignStatus(null), ASSIGN_STATUS_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [assignStatus]);
const onEnterGroup = (group: Group, category: string | null) => {
playPop(category ? 440 : 380);
setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 }));
@@ -302,9 +384,43 @@ export function App() {
onMute={onMute}
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
onOpenAlbum={onOpenCurrentAlbum}
onOpenAssign={() => setAssignPopupOpen(true)}
assignPending={ui.assignPending}
/>
)}
{assignPopupOpen && currentAlbum && (
<RemoteAssignPopup
album={currentAlbum}
mapping={remoteMapping}
albums={library.albums}
onAssign={(digit) => void assignCurrentTo(digit)}
onClear={clearSlot}
onClose={() => setAssignPopupOpen(false)}
/>
)}
{assignStatus && (
<div
style={{
position: "absolute",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
zIndex: 7,
background: "var(--ink)",
color: "#fff",
fontSize: 14,
fontWeight: 800,
padding: "10px 20px",
borderRadius: 999,
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
}}
>
{assignStatus}
</div>
)}
{ui.view === "room" && haConfig && (
<RoomView
config={haConfig}