- Replace the "Mein Zimmer"/"Musik" text pills and the help button with
two fixed circular corner buttons: a music/room toggle (top-right)
and a single "back one level" button (top-left) used everywhere -
search, categories, and the play view.
- Split `view` (browse/play) from a new `page` (music/room) in UiState
so toggling to the room and back leaves the music side - search,
selection, even the full-screen play view - exactly as it was.
- Back peels one step at a time (search, then track-search mode, then
group+category together, since a root shelf tile sets both at once
and undoing that jump should be one step too).
- Album cards: click the cover to open the track list, click the title
to start playing immediately (matching what Enter already did from
the keyboard). Audiobook cards drop the artist line and clamp the
title to a fixed height so covers stay aligned across a row.
- Root shelf group headings ("Musik", "Hörbücher", "Podcasts") are now
the link into that group's full category grid, replacing the
separate search-icon button.
- "Taste zuweisen" is now an icon-only round button, moved to the
bottom-right so it doesn't sit under the new toggle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
261 lines
9.7 KiB
TypeScript
261 lines
9.7 KiB
TypeScript
/** The keyboard state machine, ported from the design mockup.
|
|
|
|
Kept pure - it takes a key and the current view state and returns what should happen -
|
|
so the whole interaction model can be tested without a DOM, and so the component that
|
|
mounts it stays a thin adapter.
|
|
|
|
The mockup's key map is followed exactly, with one addition: SHIFT+arrows seek. Plain
|
|
arrows were already taken (selection while browsing, transport and volume while
|
|
playing), and seeking is the one thing a real player can do that the mockup could not.
|
|
*/
|
|
|
|
import type { Album } from "../api/types";
|
|
import type { Group, Mode, Results } from "./search";
|
|
|
|
export interface UiState {
|
|
search: string;
|
|
mode: Mode;
|
|
/** `null` is the bare root screen (three shelves); otherwise which one is open. */
|
|
group: Group | null;
|
|
category: string | null;
|
|
selIndex: number;
|
|
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
|
|
* so toggling to the room and back leaves the music side exactly as it was. */
|
|
page: "music" | "room";
|
|
view: "browse" | "play";
|
|
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 = {
|
|
search: "",
|
|
mode: "albums",
|
|
group: null,
|
|
category: null,
|
|
selIndex: 0,
|
|
page: "music",
|
|
view: "browse",
|
|
openAlbumId: null,
|
|
showHelp: false,
|
|
cols: 4,
|
|
assignPending: false,
|
|
};
|
|
|
|
export type Action =
|
|
| { type: "ui"; patch: Partial<UiState> }
|
|
| { type: "play"; albumId: string; trackIndex: number }
|
|
| { type: "toggle" }
|
|
| { type: "next" }
|
|
| { type: "previous" }
|
|
| { type: "volume"; delta: number }
|
|
| { type: "seek"; delta: 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;
|
|
export const SEEK_STEP = 15;
|
|
|
|
/** What the flat selection index currently points at. */
|
|
export function selectionAt(results: Results, selIndex: number): Action | null {
|
|
const { songs, categories, albums } = results;
|
|
const index = Math.min(selIndex, results.total - 1);
|
|
if (index < 0) return null;
|
|
|
|
if (index < songs.length) {
|
|
const hit = songs[index];
|
|
return hit ? { type: "play", albumId: hit.album.id, trackIndex: hit.index } : null;
|
|
}
|
|
const afterSongs = index - songs.length;
|
|
if (afterSongs < categories.length) {
|
|
const category = categories[afterSongs];
|
|
return category ? { type: "ui", patch: { category: category.key, selIndex: 0 } } : null;
|
|
}
|
|
const album: Album | undefined = albums[afterSongs - categories.length];
|
|
return album ? { type: "play", albumId: album.id, trackIndex: 0 } : null;
|
|
}
|
|
|
|
function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
|
|
if (!results.total) return [];
|
|
const cols = Math.max(1, state.cols);
|
|
let index = Math.min(state.selIndex, results.total - 1);
|
|
if (dx) index += dx;
|
|
if (dy) {
|
|
// Song hits are a single-column list; everything below them is a grid.
|
|
index += index < results.songs.length ? dy : dy * cols;
|
|
}
|
|
index = Math.max(0, Math.min(index, results.total - 1));
|
|
return [{ type: "ui", patch: { selIndex: index } }];
|
|
}
|
|
|
|
/** One step of "back" within the browse hierarchy - search, then track-search mode,
|
|
* then group/category together - the same peeling order ESC and the top-left back
|
|
* button both use. `group` and `category` clear as one step because a root shelf tile
|
|
* sets both at once (jumping straight to one category's albums); undoing that jump
|
|
* should be one step too, not two, regardless of whether a category was reached that
|
|
* way or by opening the group first. Always safe to call. */
|
|
export function browseBackActions(state: UiState): Action[] {
|
|
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
|
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
|
return [{ type: "ui", patch: { group: null, category: null, selIndex: 0 } }];
|
|
}
|
|
|
|
/** 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 } }];
|
|
}
|
|
if (state.page === "room") {
|
|
return [{ type: "ui", patch: { page: "music" } }];
|
|
}
|
|
if (state.view === "play") {
|
|
return [{ type: "ui", patch: { view: "browse" } }];
|
|
}
|
|
return browseBackActions(state);
|
|
}
|
|
|
|
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */
|
|
export function isRootShelf(state: UiState): boolean {
|
|
return state.group === null && !state.search && state.mode === "albums" && state.category === null;
|
|
}
|
|
|
|
export interface KeyEvent {
|
|
key: string;
|
|
ctrlKey: boolean;
|
|
metaKey: boolean;
|
|
shiftKey: boolean;
|
|
}
|
|
|
|
/**
|
|
* Translate one keypress. Returns the actions to run, or nothing when the key means
|
|
* nothing here - the caller only calls `preventDefault()` when something came back.
|
|
*/
|
|
export function handleKey(event: KeyEvent, state: UiState, results: Results): Action[] {
|
|
const { key } = event;
|
|
|
|
if (event.ctrlKey || event.metaKey) {
|
|
switch (key.toLowerCase()) {
|
|
case "l":
|
|
return [{ type: "next" }];
|
|
case "h":
|
|
return [{ type: "previous" }];
|
|
case "k":
|
|
return [{ type: "volume", delta: VOLUME_STEP }];
|
|
case "j":
|
|
return [{ type: "volume", delta: -VOLUME_STEP }];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// 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 } }];
|
|
}
|
|
|
|
// The room page has no browse hierarchy of its own - only ESC (handled below) and
|
|
// transport controls make sense there. Everything else would otherwise mutate the
|
|
// hidden music state without anything on screen to show for it.
|
|
if (state.page === "room" && key !== "Escape") {
|
|
return key === " " ? [{ type: "pop", freq: 340 }, { type: "toggle" }] : [];
|
|
}
|
|
|
|
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
|
|
|
|
switch (key) {
|
|
case "Tab": {
|
|
const currentIndex = state.group ? GROUP_ORDER.indexOf(state.group) : -1;
|
|
const next = GROUP_ORDER[(currentIndex + 1) % GROUP_ORDER.length]!;
|
|
return [
|
|
{ type: "pop", freq: 380 },
|
|
{ type: "ui", patch: { group: next, selIndex: 0, view: "browse", category: null } },
|
|
];
|
|
}
|
|
case "/":
|
|
return [{ type: "ui", patch: { view: "browse" } }];
|
|
case " ":
|
|
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
|
|
|
|
case "ArrowRight":
|
|
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
|
if (browsing && isRootShelf(state)) {
|
|
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
|
}
|
|
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
|
|
case "ArrowLeft":
|
|
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
|
|
if (browsing && isRootShelf(state)) {
|
|
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
|
}
|
|
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
|
|
case "ArrowDown":
|
|
if (browsing && isRootShelf(state)) {
|
|
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
|
}
|
|
return browsing
|
|
? moveSelection(state, results, 0, 1)
|
|
: [{ type: "volume", delta: -VOLUME_STEP }];
|
|
case "ArrowUp":
|
|
if (browsing && isRootShelf(state)) {
|
|
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
|
}
|
|
return browsing
|
|
? moveSelection(state, results, 0, -1)
|
|
: [{ type: "volume", delta: VOLUME_STEP }];
|
|
|
|
case "?":
|
|
return [
|
|
{ type: "pop", freq: 460 },
|
|
{
|
|
type: "ui",
|
|
patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false },
|
|
},
|
|
];
|
|
case "F1":
|
|
return [{ type: "ui", patch: { showHelp: !state.showHelp } }];
|
|
case "Escape":
|
|
return escape(state);
|
|
case "Backspace":
|
|
return state.search
|
|
? [{ type: "ui", patch: { search: state.search.slice(0, -1), selIndex: 0 } }]
|
|
: [];
|
|
case "Enter": {
|
|
if (state.openAlbumId !== null) {
|
|
return [{ type: "play", albumId: state.openAlbumId, trackIndex: 0 }];
|
|
}
|
|
const chosen = selectionAt(results, state.selIndex);
|
|
return chosen ? [chosen] : [];
|
|
}
|
|
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 typeIntoSearch(state, key);
|
|
default:
|
|
return typeIntoSearch(state, key);
|
|
}
|
|
}
|