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

@@ -10,6 +10,7 @@ const KEYS: Array<[caps: string[], label: string]> = [
[["?"], "Einzelne Titel suchen"],
[["F1"], "Diese Hilfe"],
[["TAB"], "Musik / Hörbücher / Podcasts"],
[["A", "0-9"], "Aktuellen Titel einer Fernbedienungs-Taste zuweisen"],
[["← ↑ ↓ →"], "Auswahl bewegen"],
[["ENTER"], "Auswahl abspielen"],
[["ESC"], "Schließen / Suche löschen"],

View File

@@ -21,6 +21,10 @@ interface Props {
onMute: () => void;
onBrowse: () => void;
onOpenAlbum: () => void;
/** Assigning the current album/show to a remote key. `assignPending` is true right
* after "A" is pressed, waiting for the digit that completes the shortcut. */
onOpenAssign: () => void;
assignPending: boolean;
}
export function PlayView({
@@ -34,6 +38,8 @@ export function PlayView({
onMute,
onBrowse,
onOpenAlbum,
onOpenAssign,
assignPending,
}: Props) {
const position = usePlaybackClock(state.position, state.playing);
const book = album ? isBook(album) : false;
@@ -245,6 +251,32 @@ export function PlayView({
</span>
Zurück zur Suche
</button>
{album && (
<button
onClick={onOpenAssign}
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl)"
style={{
position: "absolute",
top: 28,
right: 84,
border: "none",
cursor: "pointer",
background: assignPending ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
borderRadius: 999,
padding: "11px 20px",
fontSize: 14,
fontWeight: 800,
color: assignPending ? "#fff" : "var(--ink)",
display: "flex",
alignItems: "center",
gap: 8,
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
}}
>
🎛 {assignPending ? "Zahl drücken …" : "Taste zuweisen"}
</button>
)}
</div>
</div>
);

View File

@@ -0,0 +1,141 @@
/** "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
* digit; this is the tap-only way in, for a viewer with no keyboard. */
import type { Album, RemoteMapping } from "../api/types";
import { remoteSlotView } from "../lib/remote";
import { Cover } from "./Cover";
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
interface Props {
album: Album;
mapping: RemoteMapping | null;
albums: Album[];
onAssign: (digit: string) => void;
onClear: (digit: string) => void;
onClose: () => void;
}
export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, onClose }: Props) {
const byDigit = new Map(mapping?.slots.map((slot) => [slot.digit, slot]));
return (
<div className="overlay" style={{ zIndex: 6 }} onClick={onClose}>
<div
className="sheet"
style={{ padding: "26px 30px", width: 420 }}
onClick={(event) => event.stopPropagation()}
>
<div style={{ fontSize: 17, fontWeight: 800, color: "var(--ink)", marginBottom: 4 }}>
🎛 Taste zuweisen
</div>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(45% 0.03 210)",
marginBottom: 16,
}}
>
{album.title}" antippen, welche Taste?
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(5, 1fr)",
gap: 10,
}}
>
{DIGITS.map((digit) => {
const view = remoteSlotView(byDigit.get(digit), albums);
return (
<div key={digit} style={{ position: "relative" }}>
<button
onClick={() => onAssign(digit)}
title={
view.state === "ok"
? `Taste ${digit}: ${view.album.title} → überschreiben`
: `Taste ${digit} zuweisen`
}
style={{
width: "100%",
aspectRatio: 1,
border: "none",
borderRadius: 14,
cursor: "pointer",
padding: 6,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 4,
background: view.state === "ok" ? "oklch(90% 0.04 210)" : "oklch(94% 0.01 210)",
overflow: "hidden",
}}
>
{view.state === "ok" ? (
<Cover album={view.album} size={40} radius="6px" />
) : (
<span
style={{
fontSize: 20,
fontWeight: 900,
color:
view.state === "missing" ? "oklch(55% 0.15 30)" : "oklch(60% 0.02 210)",
}}
>
{view.state === "missing" ? "" : "+"}
</span>
)}
<span style={{ fontSize: 13, fontWeight: 900, color: "var(--ink)" }}>
{digit}
</span>
</button>
{view.state !== "empty" && (
<button
onClick={(event) => {
event.stopPropagation();
onClear(digit);
}}
aria-label={`Taste ${digit} freigeben`}
style={{
position: "absolute",
top: -6,
right: -6,
width: 22,
height: 22,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: "var(--ink)",
color: "#fff",
fontSize: 12,
fontWeight: 900,
lineHeight: 1,
}}
>
×
</button>
)}
</div>
);
})}
</div>
<div
style={{
marginTop: 16,
fontSize: 12,
fontWeight: 600,
color: "oklch(50% 0.03 210 / .8)",
textAlign: "center",
}}
>
Tippe daneben, um zu schließen
</div>
</div>
</div>
);
}