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:
120
web/src/App.tsx
120
web/src/App.tsx
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user