Number keys: the keyboard's digits work like the remote's; a key can hold one song

The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.

A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:41:34 +02:00
parent 92305bece0
commit 459599d2ba
22 changed files with 449 additions and 75 deletions

View File

@@ -37,7 +37,7 @@ import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
import type { Action, UiState } from "./lib/keyboard";
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote";
import { canAssignSong, targetForAlbum } from "./lib/remote";
import type { Group, Mode, Results, SongHit } from "./lib/search";
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
import { SKIN } from "./lib/skins";
@@ -228,7 +228,7 @@ export function App() {
const slots: Record<string, RemoteSlotInput> = Object.fromEntries(
(remoteMapping?.slots ?? []).map((slot) => [
slot.digit,
{ target_kind: slot.target_kind, target: slot.target },
{ target_kind: slot.target_kind, target: slot.target, track_index: slot.track_index },
]),
);
mutate(slots);
@@ -240,20 +240,35 @@ export function App() {
);
const assignCurrentTo = useCallback(
async (digit: string) => {
async (digit: string, fromTrack: boolean) => {
if (!currentAlbum) return;
const target = targetForAlbum(currentAlbum);
const song = fromTrack && canAssignSong(currentAlbum) ? (state?.track_index ?? 0) : 0;
const target = targetForAlbum(currentAlbum, song);
const songTitle = target.track_index ? currentAlbum.tracks[song]?.title : undefined;
try {
await saveMapping((slots) => {
slots[digit] = target;
});
setAssignStatus(`Taste ${digit}: „${currentAlbum.title}"`);
setAssignStatus(`Taste ${digit}: „${songTitle ?? currentAlbum.title}"`);
} catch {
setAssignStatus("Konnte nicht zugewiesen werden.");
}
setAssignPopupOpen(false);
},
[currentAlbum, saveMapping],
[currentAlbum, saveMapping, state?.track_index],
);
const pressRemote = useCallback(
(digit: string) => {
// Shown at once, like `play`, when the key is known to play something; an empty
// key stays where it is, as the remote's own press of it does nothing either.
const slot = remoteMapping?.slots.find((candidate) => candidate.digit === digit);
if (slot?.resolved_album_id) {
setUi((previous) => ({ ...previous, page: "music", view: "play", openAlbumId: null, showHelp: false }));
}
void api.pressRemote(digit);
},
[remoteMapping],
);
const clearSlot = useCallback(
@@ -279,7 +294,10 @@ export function App() {
toggle();
break;
case "assign":
void assignCurrentTo(action.digit);
void assignCurrentTo(action.digit, action.fromTrack);
break;
case "remote":
pressRemote(action.digit);
break;
case "next":
playPop(260);
@@ -330,6 +348,7 @@ export function App() {
currentAlbum,
onMute,
play,
pressRemote,
seekTo,
setVolume,
skipPodcast,
@@ -374,7 +393,7 @@ export function App() {
useEffect(() => {
if (!ui.assignPending) return;
const timer = setTimeout(
() => setUi((previous) => ({ ...previous, assignPending: false })),
() => setUi((previous) => ({ ...previous, assignPending: null })),
ASSIGN_PENDING_TIMEOUT_MS,
);
return () => clearTimeout(timer);
@@ -536,7 +555,7 @@ export function App() {
onMute={onMute}
onOpenAlbum={onOpenCurrentAlbum}
onOpenAssign={() => setAssignPopupOpen(true)}
assignPending={ui.assignPending}
assignPending={ui.assignPending !== null}
/>
)}
@@ -545,7 +564,8 @@ export function App() {
album={currentAlbum}
mapping={remoteMapping}
albums={library.albums}
onAssign={(digit) => void assignCurrentTo(digit)}
currentTrackIndex={state?.track_index ?? 0}
onAssign={(digit, fromTrack) => void assignCurrentTo(digit, fromTrack)}
onClear={clearSlot}
onClose={() => setAssignPopupOpen(false)}
/>

View File

@@ -111,6 +111,7 @@ export const api = {
trackDetail: fetchTrackDetail,
lircConfig: fetchLircConfig,
pressRemote: (digit: string) => post(`/remote/press/${digit}`),
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),

View File

@@ -127,8 +127,9 @@ export interface HaEntityState {
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`),
/** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, started
* from `track_index` (0 for the whole album; otherwise one song, then the rest of the
* album). "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`. */
@@ -137,6 +138,7 @@ export interface RemoteSlot {
target_kind: RemoteTargetKind;
target: string;
resolved_album_id: string | null;
track_index: number;
}
export interface RemoteMapping {
@@ -146,6 +148,7 @@ export interface RemoteMapping {
export interface RemoteSlotInput {
target_kind: RemoteTargetKind;
target: string;
track_index?: number;
}
/** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */

View File

@@ -12,6 +12,7 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string
[["⇧J", "⇧K"], "Leiser / lauter"],
[["⇧M"], "Stumm"],
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
[["0-9"], "Wie die Zahlen auf der Fernbedienung"],
],
],
[
@@ -35,7 +36,8 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string
[
"Sonstiges",
[
[["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"],
[["A", "0-9"], "Album auf Fernbedienungs-Taste legen"],
[["⇧A", "0-9"], "Nur dieses Lied auf die Taste legen"],
[["ENTF"], "Bildschirm aus / an"],
[["F1"], "Diese Hilfe"],
[["ESC"], "Schließen / Suche löschen"],

View File

@@ -233,7 +233,7 @@ export function PlayView({
<button
onClick={onOpenAssign}
aria-label={assignPending ? "Zahl drücken …" : "Taste zuweisen"}
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl)"
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl, Shift+A für nur dieses Lied)"
style={{
position: "absolute",
bottom: 28,

View File

@@ -1,9 +1,12 @@
/** "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. */
* or podcast show to a number key on the IR remote. Same job as pressing "A" (or
* Shift+"A" for the song) then a digit; this is the tap-only way in, for a viewer with
* no keyboard. */
import { useState, type ReactNode } from "react";
import type { Album, RemoteMapping } from "../api/types";
import { remoteSlotView } from "../lib/remote";
import { canAssignSong, remoteSlotView } from "../lib/remote";
import { Cover } from "./Cover";
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
@@ -11,15 +14,63 @@ const ZERO_COLUMN = 2;
interface Props {
album: Album;
/** The track playing now - what "ab diesem Lied" assigns. */
currentTrackIndex: number;
mapping: RemoteMapping | null;
albums: Album[];
onAssign: (digit: string) => void;
onAssign: (digit: string, fromTrack: boolean) => void;
onClear: (digit: string) => void;
onClose: () => void;
}
export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, onClose }: Props) {
function ChoiceButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
aria-pressed={active}
style={{
flex: 1,
minWidth: 0,
border: "none",
cursor: "pointer",
padding: "8px 10px",
borderRadius: 10,
fontSize: 13,
fontWeight: 800,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
background: active ? "var(--accent)" : "transparent",
color: active ? "#fff" : "var(--ink)",
}}
>
{children}
</button>
);
}
export function RemoteAssignPopup({
album,
currentTrackIndex,
mapping,
albums,
onAssign,
onClear,
onClose,
}: Props) {
const byDigit = new Map(mapping?.slots.map((slot) => [slot.digit, slot]));
const songChoice = canAssignSong(album);
const currentTrack = album.tracks[currentTrackIndex];
const [fromTrack, setFromTrack] = useState(false);
const assignsSong = songChoice && fromTrack;
return (
<div className="overlay" style={{ zIndex: 6 }} onClick={onClose}>
@@ -39,9 +90,29 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
marginBottom: 16,
}}
>
„{album.title}" antippen, welche Taste?
„{assignsSong && currentTrack ? currentTrack.title : album.title}" – welche Taste?
</div>
{songChoice && (
<div
style={{
display: "flex",
gap: 4,
padding: 4,
marginBottom: 16,
borderRadius: 14,
background: "oklch(94% 0.01 var(--hue))",
}}
>
<ChoiceButton active={!fromTrack} onClick={() => setFromTrack(false)}>
Ganzes Album
</ChoiceButton>
<ChoiceButton active={fromTrack} onClick={() => setFromTrack(true)}>
Ab diesem Lied{currentTrack ? `: ${currentTrack.title}` : ""}
</ChoiceButton>
</div>
)}
<div
style={{
display: "grid",
@@ -60,10 +131,10 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
}}
>
<button
onClick={() => onAssign(digit)}
onClick={() => onAssign(digit, assignsSong)}
title={
view.state === "ok"
? `Taste ${digit}: ${view.album.title} → überschreiben`
? `Taste ${digit}: ${view.track?.title ?? view.album.title} → überschreiben`
: `Taste ${digit} zuweisen`
}
style={{
@@ -98,6 +169,11 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
)}
<span style={{ fontSize: 13, fontWeight: 900, color: "var(--ink)" }}>
{digit}
{view.state === "ok" && view.track && (
<span style={{ fontWeight: 700, opacity: 0.7 }}>
{" "}· Lied {view.slot.track_index + 1}
</span>
)}
</span>
</button>
{view.state !== "empty" && (

View File

@@ -396,9 +396,11 @@ describe("selectionAlbumAt", () => {
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 arms assigning the album while something is playing, Shift+A the song", () => {
expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: "album" } }]);
expect(press("A", playing, { shiftKey: true })).toEqual([
{ type: "ui", patch: { assignPending: "track" } },
]);
});
it("a plain a still types into search everywhere else", () => {
@@ -411,22 +413,49 @@ describe("remote key assignment", () => {
});
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 } },
expect(press("5", { ...playing, assignPending: "album" })).toEqual([
{ type: "assign", digit: "5", fromTrack: false },
{ type: "ui", patch: { assignPending: null } },
]);
});
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 } },
expect(press("5", { ...playing, assignPending: "track" })).toEqual([
{ type: "assign", digit: "5", fromTrack: true },
{ type: "ui", patch: { assignPending: null } },
]);
});
it("ESC disarms before doing anything else", () => {
const armed: UiState = { ...playing, assignPending: true };
expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: false } }]);
const armed: UiState = { ...playing, assignPending: "album" };
expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: null } }]);
});
});
describe("number keys", () => {
const remote = (digit: string) => [
{ type: "pop", freq: 420 },
{ type: "remote", digit },
];
it("press the IR remote's key on the root, browse, play and room screens", () => {
expect(press("5", initialUiState)).toEqual(remote("5"));
expect(press("0", { ...initialUiState, group: "music" })).toEqual(remote("0"));
expect(press("3", { ...initialUiState, view: "play" })).toEqual(remote("3"));
expect(press("7", { ...initialUiState, page: "room" })).toEqual(remote("7"));
});
it("type into the search once the search box is open", () => {
expect(press("5", { ...initialUiState, search: "folge " })).toEqual([
{ type: "ui", patch: { search: "folge 5", view: "browse", selIndex: 0 } },
]);
expect(press("5", { ...initialUiState, searchOpen: true })).toEqual([
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
]);
expect(press("5", { ...initialUiState, mode: "tracks" })).toEqual([
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
]);
});
it("are left alone on the typing tab", () => {
expect(press("5", { ...initialUiState, page: "typing" })).toEqual([]);
});
});

View File

@@ -31,6 +31,14 @@ describe("targetForAlbum", () => {
});
});
it("starts at one song when given its index", () => {
expect(targetForAlbum(album("a"), 3)).toEqual({
target_kind: "album",
target: "a",
track_index: 3,
});
});
it("targets the show, not the episode, for a podcast", () => {
const episode = album("ep1", {
kind: "book",
@@ -57,20 +65,36 @@ describe("remoteSlotView", () => {
target_kind: "album",
target: "resolved-id",
resolved_album_id: "resolved-id",
track_index: 0,
};
expect(remoteSlotView(slot, albums)).toEqual({
state: "ok",
slot,
album: albums[0],
track: null,
});
});
it("names the song when the slot starts at one", () => {
const track = { title: "Lied 2", duration: 60, analysis: null, locked: false, unlock_hint: null };
const withTracks = [album("resolved-id", { tracks: [{ ...track, title: "Lied 1" }, track] })];
const slot: RemoteSlot = {
digit: "3",
target_kind: "album",
target: "resolved-id",
resolved_album_id: "resolved-id",
track_index: 1,
};
expect(remoteSlotView(slot, withTracks)).toMatchObject({ state: "ok", track });
});
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,
track_index: 0,
};
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
});
@@ -81,6 +105,7 @@ describe("remoteSlotView", () => {
target_kind: "album",
target: "gone",
resolved_album_id: "gone",
track_index: 0,
};
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
});

View File

@@ -36,9 +36,10 @@ export interface UiState {
modalTrackIndex: number;
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;
/** Armed on the play screen: the next digit assigns what's playing to that remote key
* instead of doing whatever it would normally do. `a` arms "album" (the whole album
* or show), Shift+`A` arms "track" (the song playing now, then the rest of the album). */
assignPending: "album" | "track" | null;
}
export const initialUiState: UiState = {
@@ -55,7 +56,7 @@ export const initialUiState: UiState = {
modalTrackIndex: 0,
showHelp: false,
cols: 4,
assignPending: false,
assignPending: null,
};
export type Action =
@@ -67,7 +68,9 @@ export type Action =
| { type: "volume"; delta: number }
| { type: "seek"; delta: number }
| { type: "pop"; freq: number }
| { type: "assign"; digit: string }
| { type: "assign"; digit: string; fromTrack: boolean }
/** A number key, doing what the same key on the IR remote does. */
| { type: "remote"; digit: string }
| { type: "mute" }
| { type: "screen" };
@@ -190,7 +193,7 @@ export function browseBackActions(state: UiState): Action[] {
/** 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 } }];
return [{ type: "ui", patch: { assignPending: null } }];
}
if (state.showHelp || state.openAlbumId !== null) {
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
@@ -283,7 +286,17 @@ export function handleKey(
// 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 } }];
return [
{ type: "assign", digit: key, fromTrack: state.assignPending === "track" },
{ type: "ui", patch: { assignPending: null } },
];
}
// A number key does what the IR remote's does - from every page bar the typing tab
// (handled above), the room page included, since the remote works from there too.
// Mid-search a digit is part of the query instead ("Folge 12").
if (/^[0-9]$/.test(key) && !event.ctrlKey && !event.metaKey && !searchBoxShown(state)) {
return [{ type: "pop", freq: 420 }, { type: "remote", digit: key }];
}
// The room page has no browse hierarchy of its own - only ESC (handled below) and
@@ -423,9 +436,10 @@ export function handleKey(
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.
// just the first letter of a search, like any other key. Shift picks the song
// playing now rather than the whole album.
if (state.view === "play" && !state.showHelp && state.openAlbumId === null) {
return [{ type: "ui", patch: { assignPending: true } }];
return [{ type: "ui", patch: { assignPending: event.shiftKey ? "track" : "album" } }];
}
return typeIntoSearch(state, key);
default:

View File

@@ -6,26 +6,36 @@ decides what a press actually assigns, and so a slot's display state - "nothing
inside the component that renders it.
*/
import type { Album, RemoteSlot, RemoteSlotInput } from "../api/types";
import type { Album, RemoteSlot, RemoteSlotInput, Track } 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.
* to whatever is newest on every press, and `trackIndex` is ignored. Everything else
* targets the album itself, started from `trackIndex`: 0 for the whole album, or one
* song followed by the rest of it.
*/
export function targetForAlbum(album: Album): RemoteSlotInput {
export function targetForAlbum(album: Album, trackIndex = 0): RemoteSlotInput {
if (groupOf(album) === "podcasts" && album.series) {
return { target_kind: "series", target: album.series };
}
return { target_kind: "album", target: album.id };
return trackIndex > 0
? { target_kind: "album", target: album.id, track_index: trackIndex }
: { target_kind: "album", target: album.id };
}
/** Whether assigning one song of `album`, rather than all of it, means anything: not
* for a podcast (the show is assigned, not an episode) or an album of one track. */
export function canAssignSong(album: Album): boolean {
return groupOf(album) !== "podcasts" && album.tracks.length > 1;
}
export type RemoteSlotView =
| { state: "empty" }
| { state: "ok"; slot: RemoteSlot; album: Album }
/** `track` is the song the key starts at, or `null` for the whole album. */
| { state: "ok"; slot: RemoteSlot; album: Album; track: Track | null }
| { state: "missing"; slot: RemoteSlot };
/** How one digit's card should render, given the current mapping and the library. */
@@ -37,5 +47,7 @@ export function remoteSlotView(
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 };
if (!album) return { state: "missing", slot };
const track = slot.track_index > 0 ? (album.tracks[slot.track_index] ?? null) : null;
return { state: "ok", slot, album, track };
}