/** 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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "./api/client"; import type { Album, HaConfig } from "./api/types"; import { AlbumModal } from "./components/AlbumModal"; import { AppHeader } from "./components/AppHeader"; import { BrowseView } from "./components/BrowseView"; import { Bubbles } from "./components/Bubbles"; import { HelpOverlay } from "./components/HelpOverlay"; import { ParentPanel } from "./components/ParentPanel"; import { PlayerBar } from "./components/PlayerBar"; import { PlayView } from "./components/PlayView"; import { RoomView } from "./components/RoomView"; import { useGridColumns } from "./hooks/useGridColumns"; import { useLibrary } from "./hooks/useLibrary"; import { usePlayerState } from "./hooks/usePlayerState"; import type { Action, UiState } from "./lib/keyboard"; import { handleKey, initialUiState } from "./lib/keyboard"; import { playPop } from "./lib/pop"; import type { Group, Results, SongHit } from "./lib/search"; import { groupOf, results as computeResults } from "./lib/search"; /** 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(); const [parentMode, setParentMode] = useState( () => new URLSearchParams(location.search).get("parentMode") === "1", ); // 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); }, []); const state = connection.state; useEffect(() => { setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns })); }, [columns]); const results: Results = useMemo( () => computeResults({ albums: library.albums, search: ui.search, mode: ui.mode, group: ui.group, category: ui.category, }), [library.albums, ui.search, ui.mode, ui.group, ui.category], ); 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]); /** The one place a keyboard action, a click or a tap all end up. */ 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 "next": playPop(260); if (currentAlbum && groupOf(currentAlbum) === "podcasts") { const target = Math.min( state?.duration ?? 0, (state?.position ?? 0) + PODCAST_SKIP_SECONDS, ); connection.optimistic({ position: target }); void api.seek(target); } else { connection.optimistic({ position: 0 }); void api.next(); } break; case "previous": playPop(260); if (currentAlbum && groupOf(currentAlbum) === "podcasts") { const target = Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS); connection.optimistic({ position: target }); void api.seek(target); } 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), ); connection.optimistic({ position: target }); void api.seek(target); } break; } case "pop": playPop(action.freq); break; } } }, [connection, currentAlbum, play, setVolume, state, toggle], ); // Held in a ref so the listener is installed once rather than on every state change. const latest = useRef({ ui, results, run }); latest.current = { ui, results, run }; 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); if (!actions.length) return; event.preventDefault(); current.run(actions); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); const onEnterGroup = (group: Group, category: string | null) => { playPop(category ? 440 : 380); setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 })); }; const onBackToRoot = () => { playPop(380); setUi((previous) => ({ ...previous, group: null, category: null, selIndex: 0 })); }; const onCategory = (key: string | null) => { if (key) playPop(440); setUi((previous) => ({ ...previous, category: key, selIndex: 0 })); }; const onOpenAlbum = (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 })); }; const onOpenCurrentAlbum = () => { if (!currentAlbum) return; playPop(420); setUi((previous) => ({ ...previous, openAlbumId: currentAlbum.id })); }; const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index); const onSeek = (target: number) => { connection.optimistic({ position: target }); void api.seek(target); }; const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT); return (
{ui.view === "browse" && state && (
} link={ haConfig ? { label: "💡 Mein Zimmer", onClick: () => setUi((previous) => ({ ...previous, view: "room" })), } : undefined } />
)} {ui.view === "play" && state && ( run([{ type: "next" }])} onPrevious={() => run([{ type: "previous" }])} onSeek={onSeek} onVolume={setVolume} onMute={onMute} onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))} onOpenAlbum={onOpenCurrentAlbum} /> )} {ui.view === "room" && haConfig && ( setUi((previous) => ({ ...previous, view: "browse" }))} /> )} {openAlbum && state && ( setUi((previous) => ({ ...previous, openAlbumId: null }))} onPlay={(trackIndex) => play(openAlbum.id, trackIndex)} /> )} {ui.showHelp && ( setUi((previous) => ({ ...previous, showHelp: false }))} /> )} {ui.view === "browse" && state && ( run([{ type: "next" }])} onPrevious={() => run([{ type: "previous" }])} onSeek={onSeek} onVolume={setVolume} onMute={onMute} onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))} /> )} {parentMode && setParentMode(false)} />} {(!state || library.loading) && }
); } function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) { const label = !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null; if (!label) return null; return (
{label}
); } function Splash({ error }: { error: string | null }) { return (
{error ? "Der Delphin ist nicht erreichbar …" : "Einen Moment …"}
); }