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

@@ -1,6 +1,16 @@
/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
import type { Album, HaConfig, HaEntityState, PlayerState, Settings, TrackDetail } from "./types";
import type {
Album,
HaConfig,
HaEntityState,
LircConfig,
PlayerState,
RemoteMapping,
RemoteSlotInput,
Settings,
TrackDetail,
} from "./types";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api${path}`, {
@@ -29,6 +39,15 @@ async function fetchHaConfig(): Promise<HaConfig | null> {
return (await response.json()) as HaConfig;
}
/** `null` means the IR remote isn't configured, not an error - same convention as
* `fetchHaConfig`. */
async function fetchLircConfig(): Promise<LircConfig | null> {
const response = await fetch("/api/lirc");
if (response.status === 404) return null;
if (!response.ok) throw new Error(`GET /lirc failed: ${response.status}`);
return (await response.json()) as LircConfig;
}
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
* right now" (the backend answers the latter with a 502) - the room page treats a
* device with no state the same way either way, rather than crashing on a poll. */
@@ -80,6 +99,11 @@ export const api = {
post(`/ha/services/${domain}/${service}`, body),
trackDetail: fetchTrackDetail,
lircConfig: fetchLircConfig,
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
};
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;