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:
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
141
web/src/components/RemoteAssignPopup.tsx
Normal file
141
web/src/components/RemoteAssignPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user