/** Owns the browse state, mounts the keyboard handler, and switches between views. * * The split that matters: everything about *what is playing* comes from the backend * over the websocket, and everything about *what you are looking at* lives here. The * mockup kept both in one object; a real player has other front-ends - the buttons on * the mouse, a figure on the reader, Home Assistant - and this UI has to follow them. */ import type { ReactNode } from "react"; import { Suspense, lazy, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { api } from "./api/client"; import { PERF_ENABLED, PERF } from "./lib/perfSettings"; import { PerfPanel } from "./components/PerfPanel"; import { useCoverWarmup } from "./hooks/useCoverWarmup"; import { useEvent } from "./hooks/useEvent"; 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"; import { AppHeader } from "./components/AppHeader"; import { BrowseView } from "./components/BrowseView"; import { HelpOverlay } from "./components/HelpOverlay"; import { MobileBrowse } from "./components/mobile/MobileBrowse"; import { MobileDock } from "./components/mobile/MobileDock"; import { ParentPanel } from "./components/ParentPanel"; import { PlayerBar } from "./components/PlayerBar"; import { PlayView } from "./components/PlayView"; import { RemoteAssignPopup } from "./components/RemoteAssignPopup"; import { TabRail } from "./components/TabRail"; import { useGridColumns } from "./hooks/useGridColumns"; import { useLibrary } from "./hooks/useLibrary"; import { usePhoneLayout } from "./hooks/usePhoneLayout"; import { usePlayerState } from "./hooks/usePlayerState"; import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables"; 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 type { Group, Mode, Results, SongHit } from "./lib/search"; import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search"; import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme"; // The room page and the typing game are each a tab away from the player and together a // large share of the bundle; the kiosk parses and compiles only what it opens. const RoomView = lazy(() => import("./components/RoomView").then((m) => ({ default: m.RoomView }))); const TippenApp = lazy(() => import("./components/TippenApp").then((m) => ({ default: m.TippenApp })), ); /** 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; /** A podcast episode has no next/previous track to skip to - Next/Previous nudge the * position instead, the way scrubbing past an ad break usually works. */ const PODCAST_SKIP_SECONDS = 30; export function App() { const [ui, setUi] = useState(initialUiState); const library = useLibrary(); const connection = usePlayerState(library.reload); const [gridRef, columns] = useGridColumns(); // A phone gets its own touch layout (components/mobile/); everything else - the // kiosk, a desktop browser, a tablet - keeps the keyboard-first one. const phone = usePhoneLayout(); const [parentMode, setParentMode] = useState( () => new URLSearchParams(location.search).get("parentMode") === "1", ); const [perfOpen, setPerfOpen] = useState( () => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1", ); const [debugDynamicUI] = useState( () => new URLSearchParams(location.search).get("debugDynamicUI") === "1", ); const [debugSnapshot, setDebugSnapshot] = useState(null); // Exists regardless of `debugDynamicUI` (so `Ambience` always has consistent, // harmless defaults - `manual.enabled` defaults to false) - only the overlay, and // thus the ability to change either, is gated behind the query param. const [tunables, setTunables] = useState(DEFAULT_TUNABLES); const [manual, setManual] = useState(DEFAULT_MANUAL_CONTROL); // undefined: not yet resolved (hide the nav pill to avoid a flash). null: confirmed // absent - the room page is a separate opt-in feature, off by default. const [haConfig, setHaConfig] = useState(undefined); useEffect(() => { 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(null); const [assignPopupOpen, setAssignPopupOpen] = useState(false); const [assignStatus, setAssignStatus] = useState(null); useEffect(() => { void api.remoteMapping().then(setRemoteMapping); }, []); const state = connection.state; useEffect(() => { setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns })); }, [columns]); // Normalized search keys, group partitions and each shelf's category list, derived // once per library payload instead of per keystroke - see lib/search.ts. The library // only reloads when the backend says a rescan finished, so this is genuinely rare. const index = useMemo(() => buildSearchIndex(library.albums), [library.albums]); // Kiosk only: fetch and decode every card thumbnail up front, in idle time. useCoverWarmup(library.albums, PERF.coverWarmup); // Typing updates `ui.search` at once and the search box with it; the results follow a // deferred copy, so a render for an older query is dropped when the next letter lands // instead of being finished first. const deferredSearch = useDeferredValue(ui.search); const results: Results = useMemo( () => computeResults({ index, search: deferredSearch, mode: ui.mode, group: ui.group, category: ui.category, }), [index, deferredSearch, ui.mode, ui.group, ui.category], ); // The bare root's three shelves, by category key only: the keyboard handler needs // these to know how far Ctrl+hjkl can move within a row and which category ENTER // opens. BrowseView renders the same shelves from the same precomputed lists. const rootShelves = useMemo( () => GROUP_ORDER.map((group) => categoryMatches({ index, search: "", mode: "albums", category: null, group, }).map((entry) => entry.key), ), [index], ); const byId = useMemo( () => new Map(library.albums.map((album) => [album.id, album])), [library.albums], ); const currentAlbum: Album | null = state?.album_id ? (byId.get(state.album_id) ?? null) : null; const openAlbum: Album | null = ui.openAlbumId ? (byId.get(ui.openAlbumId) ?? null) : null; const play = useCallback((albumId: string, trackIndex: number) => { playPop(300); setUi((previous) => ({ ...previous, view: "play", openAlbumId: null })); void api.play(albumId, trackIndex); }, []); const setVolume = useCallback( (percent: number) => { const clamped = Math.max(0, Math.min(100, percent)); // Optimistic, so the bars move on the keypress rather than a frame later. connection.optimistic({ volume: clamped }); void api.setVolume(clamped); }, [connection], ); const toggle = useCallback(() => { if (!state) return; if (state.album_id === null) { // Nothing loaded yet: space starts whatever is on screen, as in the mockup. const first = results.albums[0] ?? library.albums[0]; if (first) play(first.id, 0); return; } connection.optimistic({ playing: !state.playing }); void (state.playing ? api.pause() : api.resume()); }, [connection, library.albums, play, results.albums, state]); const onMute = useCallback( () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT), [setVolume, state], ); /** The one seek primitive: an optimistic position patch plus the backend call. * Shared by dragging the progress bar (an absolute target) and Shift+Arrow / * podcast skip (a target computed from the current position). */ const seekTo = useCallback( (target: number) => { connection.optimistic({ position: target }); void api.seek(target); }, [connection], ); const skipPodcast = useCallback( (direction: 1 | -1) => { const target = direction > 0 ? Math.min(state?.duration ?? 0, (state?.position ?? 0) + PODCAST_SKIP_SECONDS) : Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS); seekTo(target); }, [seekTo, 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) => void) => { const slots: Record = 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) { switch (action.type) { case "ui": setUi((previous) => ({ ...previous, ...action.patch })); break; case "play": play(action.albumId, action.trackIndex); break; case "toggle": toggle(); break; case "assign": void assignCurrentTo(action.digit); break; case "next": playPop(260); if (currentAlbum && groupOf(currentAlbum) === "podcasts") { skipPodcast(1); } else { connection.optimistic({ position: 0 }); void api.next(); } break; case "previous": playPop(260); if (currentAlbum && groupOf(currentAlbum) === "podcasts") { skipPodcast(-1); } else { connection.optimistic({ position: 0 }); void api.previous(); } break; case "volume": setVolume((state?.volume ?? 0) + action.delta); break; case "seek": { if (state?.duration) { const target = Math.max( 0, Math.min(state.duration, (state?.position ?? 0) + action.delta), ); seekTo(target); } break; } case "pop": playPop(action.freq); break; case "mute": onMute(); break; } } }, [ assignCurrentTo, connection, currentAlbum, onMute, play, seekTo, setVolume, skipPodcast, state, toggle, ], ); // Held in a ref so the listener is installed once rather than on every state change. const latest = useRef({ ui, results, run, openAlbumTrackCount: 0, rootShelves }); latest.current = { ui, results, run, openAlbumTrackCount: openAlbum?.tracks.length ?? 0, rootShelves, }; useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; // Parent mode has real text fields; typing a title into one must not start a song. if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; const current = latest.current; const actions = handleKey( event, current.ui, current.results, current.openAlbumTrackCount, current.rootShelves, ); if (!actions.length) return; event.preventDefault(); current.run(actions); }; window.addEventListener("keydown", onKeyDown); 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 = useEvent((group: Group, category: string | null) => { playPop(category ? 440 : 380); setUi((previous) => ({ ...previous, group, category, search: "", searchOpen: false, selIndex: 0 })); }); const onCategory = useEvent((key: string | null) => { if (key) playPop(440); setUi((previous) => ({ ...previous, category: key, selIndex: 0 })); }); const onOpenAlbum = useEvent((album: Album, navIndex: number) => { // A podcast episode is a single track behaving like an audiobook of one chapter - // there's nothing a metadata popup would add, so it just starts playing. if (groupOf(album) === "podcasts") { setUi((previous) => ({ ...previous, selIndex: navIndex })); play(album.id, 0); return; } playPop(420); setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex, modalTrackIndex: 0 })); }); /** Clicking the title text (as opposed to the cover) starts the album right away - * the same thing ENTER does from the keyboard - instead of opening the track list. */ const onPlayAlbum = useEvent((album: Album, navIndex: number) => { setUi((previous) => ({ ...previous, selIndex: navIndex })); play(album.id, 0); }); const onOpenCurrentAlbum = () => { if (!currentAlbum) return; playPop(420); // currentAlbum is by definition whatever's playing, so its own track_index is the // track already highlighted - the modal opens showing where you actually are. setUi((previous) => ({ ...previous, openAlbumId: currentAlbum.id, modalTrackIndex: state?.track_index ?? 0, })); }; const onPlaySong = useEvent((hit: SongHit) => play(hit.album.id, hit.index)); const onMode = useEvent((mode: Mode) => setUi((previous) => ({ ...previous, mode, selIndex: 0 }))); const onNext = useCallback(() => run([{ type: "next" }]), [run]); const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]); // The single top-left "back" button, shared by every level of the music page - // search, categories, play view - so it always means "one step out" no matter what // is on screen. Nothing to back out of on the room page (it has no sub-levels), and // the toggle button is what moves between the two pages. // The phone layout has its own back button in the browse screen's nav bar. const canGoBack = ui.page === "music" && (ui.view === "play" || (!phone && !isRootShelf(ui))); const onBack = () => { if (ui.view === "play") { setUi((previous) => ({ ...previous, view: "browse" })); return; } run(browseBackActions(ui)); }; const onSelectPage = (page: UiState["page"]) => setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page })); // Tabs, unlike the rail's toggles: tapping the open one again leaves it open. const onSelectPhonePage = (page: UiState["page"]) => setUi((previous) => ({ ...previous, page, search: "", searchOpen: false })); return (
{state && SHOW_AMBIENCE && ( )} {phone && ui.page === "music" && ui.view === "browse" && state && (
)} {!phone && ui.page === "music" && ui.view === "browse" && state && (
} /> run(toggleModeActions(ui))} columns={columns} category={ui.category} selIndex={ui.selIndex} shelfRow={ui.shelfRow} currentAlbumId={state.album_id} gridRef={gridRef} onEnterGroup={onEnterGroup} onCategory={onCategory} onOpenAlbum={onOpenAlbum} onPlayAlbum={onPlayAlbum} onPlaySong={onPlaySong} />
)} {ui.page === "music" && ui.view === "play" && state && ( setAssignPopupOpen(true)} assignPending={ui.assignPending} /> )} {assignPopupOpen && currentAlbum && ( void assignCurrentTo(digit)} onClear={clearSlot} onClose={() => setAssignPopupOpen(false)} /> )} {assignStatus && (
{assignStatus}
)} {ui.page === "room" && haConfig && } {ui.page === "typing" && ( setUi((previous) => ({ ...previous, page: "music" }))} /> )} {openAlbum && state && ( setUi((previous) => ({ ...previous, openAlbumId: null }))} onPlay={(trackIndex) => play(openAlbum.id, trackIndex)} phone={phone} /> )} {canGoBack && ( )} {!phone && } {phone && (ui.page === "room" || (ui.page === "music" && ui.view === "browse")) && ( setUi((previous) => ({ ...previous, searchOpen: true }))} onCloseSearch={() => setUi((previous) => ({ ...previous, search: "", searchOpen: false, mode: "albums" })) } onSearchChange={(text) => setUi((previous) => ({ ...previous, search: text, selIndex: 0 }))} state={state} album={currentAlbum} onToggle={toggle} onNext={onNext} onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))} /> )} {ui.showHelp && ( setUi((previous) => ({ ...previous, showHelp: false }))} /> )} {!phone && ui.page === "music" && ui.view === "browse" && state && currentAlbum && ( setUi((previous) => ({ ...previous, view: "play" }))} /> )} {parentMode && setParentMode(false)} />} {PERF_ENABLED && !perfOpen && ( )} {PERF_ENABLED && perfOpen && setPerfOpen(false)} />} {/* The F1 sheet, reachable without knowing about F1. Not on a phone (no keyboard) or the typing page (TippenApp has its own help and owns every key there). */} {!phone && ui.page !== "typing" && !ui.showHelp && ( )} {(!state || library.loading) && } {debugDynamicUI && debugSnapshot && ( )}
); } /** The one fixed round button in each top corner: back (left) and the music/room * toggle (right). Same position and shape everywhere they appear, so "top left" and * "top right" always mean the same thing. */ function CornerButton({ side, onClick, label, children, }: { side: "left" | "right"; onClick: () => void; label: string; children: ReactNode; }) { return ( ); } function connectionLabel(online: boolean, state: { active_figure: string | null }): string | null { return !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null; } function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) { const label = connectionLabel(online, state); if (!label) return null; return (
{label}
); } function Splash({ error }: { error: string | null }) { return (
{error ? "Der Delphin ist nicht erreichbar …" : "Einen Moment …"}
); }