Web frontend: perf panel, cover warmup, incremental browse rendering
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user