Web frontend: perf panel, cover warmup, incremental browse rendering

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 11:42:33 +02:00
parent 9fd106b757
commit 2db4368fc9
24 changed files with 1178 additions and 255 deletions

View File

@@ -7,9 +7,13 @@
*/
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } 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";
@@ -21,22 +25,27 @@ 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 { TabRail } from "./components/TabRail";
import { TippenApp } from "./components/TippenApp";
import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary";
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 } 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, 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;
@@ -58,6 +67,9 @@ export function App() {
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",
);
@@ -97,16 +109,24 @@ export function App() {
// 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: ui.search,
search: deferredSearch,
mode: ui.mode,
group: ui.group,
category: ui.category,
}),
[index, ui.search, ui.mode, ui.group, ui.category],
[index, deferredSearch, ui.mode, ui.group, ui.category],
);
// The bare root's three shelves, by category key only: the keyboard handler needs
@@ -351,17 +371,17 @@ export function App() {
return () => clearTimeout(timer);
}, [assignStatus]);
const onEnterGroup = (group: Group, category: string | null) => {
const onEnterGroup = useEvent((group: Group, category: string | null) => {
playPop(category ? 440 : 380);
setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 }));
};
setUi((previous) => ({ ...previous, group, category, search: "", searchOpen: false, selIndex: 0 }));
});
const onCategory = (key: string | null) => {
const onCategory = useEvent((key: string | null) => {
if (key) playPop(440);
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
};
});
const onOpenAlbum = (album: Album, navIndex: number) => {
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") {
@@ -371,14 +391,14 @@ export function App() {
}
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 = (album: Album, navIndex: number) => {
const onPlayAlbum = useEvent((album: Album, navIndex: number) => {
setUi((previous) => ({ ...previous, selIndex: navIndex }));
play(album.id, 0);
};
});
const onOpenCurrentAlbum = () => {
if (!currentAlbum) return;
@@ -392,7 +412,7 @@ export function App() {
}));
};
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
const onPlaySong = useEvent((hit: SongHit) => play(hit.album.id, hit.index));
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
@@ -443,7 +463,11 @@ export function App() {
group={ui.group}
index={index}
mode={ui.mode}
search={ui.search}
search={deferredSearch}
typed={ui.search}
searchOpen={ui.searchOpen}
onToggleMode={() => run(toggleModeActions(ui))}
columns={columns}
category={ui.category}
selIndex={ui.selIndex}
shelfRow={ui.shelfRow}
@@ -506,11 +530,13 @@ export function App() {
</div>
)}
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
<Suspense fallback={null}>
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
{ui.page === "typing" && (
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
)}
{ui.page === "typing" && (
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
)}
</Suspense>
{openAlbum && state && (
<AlbumModal
@@ -559,6 +585,30 @@ export function App() {
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
{PERF_ENABLED && !perfOpen && (
<button
onClick={() => setPerfOpen(true)}
aria-label="Leistungseinstellungen"
style={{
position: "absolute",
left: 8,
bottom: 8,
zIndex: 5,
width: 32,
height: 32,
border: "none",
borderRadius: 999,
cursor: "pointer",
background: "oklch(15% 0.03 210 / .35)",
color: "#fff",
opacity: 0.5,
}}
>
⚡
</button>
)}
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
{(!state || library.loading) && <Splash error={library.error} />}
{debugDynamicUI && debugSnapshot && (