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}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -72,7 +72,7 @@ export interface PlayerState {
|
||||
/** Percent, 0..100. The device's configured range never leaves the backend. */
|
||||
volume: number;
|
||||
active_figure: string | null;
|
||||
connected: { firmware: boolean; mqtt: boolean };
|
||||
connected: { firmware: boolean; mqtt: boolean; lirc: boolean };
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -107,3 +107,31 @@ export interface HaEntityState {
|
||||
state: string;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type RemoteTargetKind = "album" | "series";
|
||||
|
||||
/** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, always
|
||||
* started from track 0. "series": `target` is a podcast show name (`Album.series`),
|
||||
* resolved to that show's newest episode fresh on every press. `resolved_album_id` is
|
||||
* what it plays *right now* - `null` when the target no longer resolves (a moved
|
||||
* album, an unknown show). Mirrors `RemoteSlotOut`. */
|
||||
export interface RemoteSlot {
|
||||
digit: string;
|
||||
target_kind: RemoteTargetKind;
|
||||
target: string;
|
||||
resolved_album_id: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteMapping {
|
||||
slots: RemoteSlot[];
|
||||
}
|
||||
|
||||
export interface RemoteSlotInput {
|
||||
target_kind: RemoteTargetKind;
|
||||
target: string;
|
||||
}
|
||||
|
||||
/** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */
|
||||
export interface LircConfig {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -231,6 +231,43 @@ describe("keyboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote key assignment", () => {
|
||||
const playing: UiState = { ...initialUiState, view: "play" };
|
||||
|
||||
it("A arms assignment while something is playing", () => {
|
||||
expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
|
||||
expect(press("A", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
|
||||
});
|
||||
|
||||
it("a plain a still types into search everywhere else", () => {
|
||||
expect(press("a", initialUiState)).toEqual([
|
||||
{ type: "ui", patch: { search: "a", view: "browse", selIndex: 0 } },
|
||||
]);
|
||||
expect(
|
||||
press("a", { ...initialUiState, view: "play", openAlbumId: "b" }),
|
||||
).toEqual([{ type: "ui", patch: { search: "a", view: "browse", selIndex: 0 } }]);
|
||||
});
|
||||
|
||||
it("a following digit assigns and disarms", () => {
|
||||
const armed: UiState = { ...playing, assignPending: true };
|
||||
expect(press("5", armed)).toEqual([
|
||||
{ type: "assign", digit: "5" },
|
||||
{ type: "ui", patch: { assignPending: false } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a digit does the normal thing when nothing is armed", () => {
|
||||
expect(press("5", initialUiState)).toEqual([
|
||||
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ESC disarms before doing anything else", () => {
|
||||
const armed: UiState = { ...playing, assignPending: true };
|
||||
expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: false } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectionAt", () => {
|
||||
it("walks songs, then categories, then albums in one flat index space", () => {
|
||||
const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" };
|
||||
|
||||
86
web/src/lib/__tests__/remote.test.ts
Normal file
86
web/src/lib/__tests__/remote.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Album, RemoteSlot } from "../../api/types";
|
||||
import { remoteSlotView, targetForAlbum } from "../remote";
|
||||
|
||||
function album(id: string, over: Partial<Album> = {}): Album {
|
||||
return {
|
||||
id,
|
||||
section: "Musik",
|
||||
kind: "music",
|
||||
title: `Album ${id}`,
|
||||
artist: "Kinderparty",
|
||||
series: null,
|
||||
figure: null,
|
||||
category: "Kinderparty",
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
tracks: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("targetForAlbum", () => {
|
||||
it("targets the album itself for music and audiobooks", () => {
|
||||
expect(targetForAlbum(album("a"))).toEqual({ target_kind: "album", target: "a" });
|
||||
expect(targetForAlbum(album("b", { kind: "book", series: "Conni" }))).toEqual({
|
||||
target_kind: "album",
|
||||
target: "b",
|
||||
});
|
||||
});
|
||||
|
||||
it("targets the show, not the episode, for a podcast", () => {
|
||||
const episode = album("ep1", {
|
||||
kind: "book",
|
||||
section: "Kinderpodcasts",
|
||||
series: "Wissen macht Ah",
|
||||
});
|
||||
expect(targetForAlbum(episode)).toEqual({
|
||||
target_kind: "series",
|
||||
target: "Wissen macht Ah",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("remoteSlotView", () => {
|
||||
const albums = [album("resolved-id")];
|
||||
|
||||
it("is empty when nothing is assigned", () => {
|
||||
expect(remoteSlotView(undefined, albums)).toEqual({ state: "empty" });
|
||||
});
|
||||
|
||||
it("is ok when the slot resolves to a known album", () => {
|
||||
const slot: RemoteSlot = {
|
||||
digit: "3",
|
||||
target_kind: "album",
|
||||
target: "resolved-id",
|
||||
resolved_album_id: "resolved-id",
|
||||
};
|
||||
expect(remoteSlotView(slot, albums)).toEqual({
|
||||
state: "ok",
|
||||
slot,
|
||||
album: albums[0],
|
||||
});
|
||||
});
|
||||
|
||||
it("is missing when the backend could not resolve the target", () => {
|
||||
const slot: RemoteSlot = {
|
||||
digit: "4",
|
||||
target_kind: "series",
|
||||
target: "no such show",
|
||||
resolved_album_id: null,
|
||||
};
|
||||
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
||||
});
|
||||
|
||||
it("is missing when the resolved album has since left the library", () => {
|
||||
const slot: RemoteSlot = {
|
||||
digit: "5",
|
||||
target_kind: "album",
|
||||
target: "gone",
|
||||
resolved_album_id: "gone",
|
||||
};
|
||||
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,9 @@ export interface UiState {
|
||||
openAlbumId: string | null;
|
||||
showHelp: boolean;
|
||||
cols: number;
|
||||
/** Armed by `A` on the play screen: the next digit assigns what's playing to that
|
||||
* remote key instead of doing whatever it would normally do. */
|
||||
assignPending: boolean;
|
||||
}
|
||||
|
||||
export const initialUiState: UiState = {
|
||||
@@ -35,6 +38,7 @@ export const initialUiState: UiState = {
|
||||
openAlbumId: null,
|
||||
showHelp: false,
|
||||
cols: 4,
|
||||
assignPending: false,
|
||||
};
|
||||
|
||||
export type Action =
|
||||
@@ -45,11 +49,20 @@ export type Action =
|
||||
| { type: "previous" }
|
||||
| { type: "volume"; delta: number }
|
||||
| { type: "seek"; delta: number }
|
||||
| { type: "pop"; freq: number };
|
||||
| { type: "pop"; freq: number }
|
||||
| { type: "assign"; digit: string };
|
||||
|
||||
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
||||
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
||||
|
||||
/** The default behaviour for a plain character key: type it into search. Shared by
|
||||
* `default` and by `a`/`A`, which only sometimes means something else. */
|
||||
function typeIntoSearch(state: UiState, key: string): Action[] {
|
||||
return SEARCHABLE.test(key)
|
||||
? [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }]
|
||||
: [];
|
||||
}
|
||||
|
||||
const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
|
||||
|
||||
export const VOLUME_STEP = 10;
|
||||
@@ -89,6 +102,9 @@ function moveSelection(state: UiState, results: Results, dx: number, dy: number)
|
||||
|
||||
/** ESC peels one layer off at a time rather than dumping you back at the top. */
|
||||
function escape(state: UiState): Action[] {
|
||||
if (state.assignPending) {
|
||||
return [{ type: "ui", patch: { assignPending: false } }];
|
||||
}
|
||||
if (state.showHelp || state.openAlbumId !== null) {
|
||||
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
||||
}
|
||||
@@ -135,6 +151,12 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
|
||||
}
|
||||
}
|
||||
|
||||
// Armed by "A" below; the next digit assigns what's playing to that remote key
|
||||
// instead of whatever it would normally do (typing into search, seeking, ...).
|
||||
if (state.assignPending && /^[0-9]$/.test(key)) {
|
||||
return [{ type: "assign", digit: key }, { type: "ui", patch: { assignPending: false } }];
|
||||
}
|
||||
|
||||
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
|
||||
|
||||
switch (key) {
|
||||
@@ -201,10 +223,15 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
|
||||
const chosen = selectionAt(results, state.selIndex);
|
||||
return chosen ? [chosen] : [];
|
||||
}
|
||||
default:
|
||||
if (SEARCHABLE.test(key)) {
|
||||
return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }];
|
||||
case "a":
|
||||
case "A":
|
||||
// Only while something is loaded on the play screen - everywhere else "a" is
|
||||
// just the first letter of a search, like any other key.
|
||||
if (state.view === "play" && !state.showHelp && state.openAlbumId === null) {
|
||||
return [{ type: "ui", patch: { assignPending: true } }];
|
||||
}
|
||||
return [];
|
||||
return typeIntoSearch(state, key);
|
||||
default:
|
||||
return typeIntoSearch(state, key);
|
||||
}
|
||||
}
|
||||
|
||||
41
web/src/lib/remote.ts
Normal file
41
web/src/lib/remote.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** Assigning the currently playing album to a number key on the IR remote.
|
||||
|
||||
Kept pure so the popup and the `A` + digit keyboard shortcut can share one place that
|
||||
decides what a press actually assigns, and so a slot's display state - "nothing here",
|
||||
"here's what plays", "this used to point somewhere real" - needs no branching logic
|
||||
inside the component that renders it.
|
||||
*/
|
||||
|
||||
import type { Album, RemoteSlot, RemoteSlotInput } from "../api/types";
|
||||
import { groupOf } from "./search";
|
||||
|
||||
/** What pressing "assign" on `album` actually stores.
|
||||
*
|
||||
* A podcast is not itself one playable thing in this library - each episode is its
|
||||
* own album - so a podcast assignment targets the *show* (its series name), resolved
|
||||
* to whatever is newest on every press. Everything else targets the album itself,
|
||||
* always started from track 0.
|
||||
*/
|
||||
export function targetForAlbum(album: Album): RemoteSlotInput {
|
||||
if (groupOf(album) === "podcasts" && album.series) {
|
||||
return { target_kind: "series", target: album.series };
|
||||
}
|
||||
return { target_kind: "album", target: album.id };
|
||||
}
|
||||
|
||||
export type RemoteSlotView =
|
||||
| { state: "empty" }
|
||||
| { state: "ok"; slot: RemoteSlot; album: Album }
|
||||
| { state: "missing"; slot: RemoteSlot };
|
||||
|
||||
/** How one digit's card should render, given the current mapping and the library. */
|
||||
export function remoteSlotView(
|
||||
slot: RemoteSlot | undefined,
|
||||
albums: Album[],
|
||||
): RemoteSlotView {
|
||||
if (!slot) return { state: "empty" };
|
||||
const album = slot.resolved_album_id
|
||||
? albums.find((candidate) => candidate.id === slot.resolved_album_id)
|
||||
: undefined;
|
||||
return album ? { state: "ok", slot, album } : { state: "missing", slot };
|
||||
}
|
||||
Reference in New Issue
Block a user