Web frontend: perf panel, cover warmup, incremental browse rendering
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
55
web/src/hooks/useCoverWarmup.ts
Normal file
55
web/src/hooks/useCoverWarmup.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Pull every card thumbnail into the browser's caches once, while the kiosk is idle.
|
||||
*
|
||||
* Cover URLs carry a version and are served `immutable`, so once a thumbnail has been
|
||||
* fetched the browser never asks for it again - but the *first* search over the whole
|
||||
* library would otherwise fetch, and decode, hundreds of them while the user waits. This
|
||||
* does that work up front, a few at a time in idle periods. The `Image`s are held so
|
||||
* their decoded bitmaps stay resident: RAM is not the constraint on the machine this is
|
||||
* for (`?pi=1`, see lib/lowPower.ts), the main thread during a keystroke is.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { coverUrl } from "../api/client";
|
||||
import type { Album } from "../api/types";
|
||||
|
||||
const BATCH = 6;
|
||||
|
||||
const idle = (run: () => void): void => {
|
||||
if (typeof requestIdleCallback === "function") requestIdleCallback(run, { timeout: 2000 });
|
||||
else setTimeout(run, 50);
|
||||
};
|
||||
|
||||
/** Module-level on purpose: dropping these would let the browser discard what they hold. */
|
||||
const held = new Map<string, HTMLImageElement>();
|
||||
|
||||
export function useCoverWarmup(albums: Album[], enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
const pending = albums.filter((a) => a.has_cover && !a.locked);
|
||||
let next = 0;
|
||||
|
||||
const step = () => {
|
||||
if (cancelled || next >= pending.length) return;
|
||||
const batch = pending.slice(next, next + BATCH);
|
||||
next += BATCH;
|
||||
void Promise.all(
|
||||
batch.map((album) => {
|
||||
const url = coverUrl(album.id, { size: "thumb", version: album.cover_v });
|
||||
if (held.has(url)) return undefined;
|
||||
const img = new Image();
|
||||
img.decoding = "async";
|
||||
img.src = url;
|
||||
held.set(url, img);
|
||||
return img.decode().catch(() => undefined);
|
||||
}),
|
||||
).then(() => idle(step));
|
||||
};
|
||||
idle(step);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [albums, enabled]);
|
||||
}
|
||||
17
web/src/hooks/useEvent.ts
Normal file
17
web/src/hooks/useEvent.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** A callback with a stable identity that always calls the latest closure.
|
||||
*
|
||||
* For handlers passed to a `memo`ised child: `App` re-renders twice a second while a
|
||||
* track plays (websocket position frames), and a fresh inline handler each time would
|
||||
* defeat `memo` and re-render the whole card grid with it. */
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
|
||||
export function useEvent<Args extends unknown[], Result>(
|
||||
handler: (...args: Args) => Result,
|
||||
): (...args: Args) => Result {
|
||||
const latest = useRef(handler);
|
||||
useLayoutEffect(() => {
|
||||
latest.current = handler;
|
||||
});
|
||||
return useCallback((...args: Args) => latest.current(...args), []);
|
||||
}
|
||||
68
web/src/hooks/useIncrementalCount.ts
Normal file
68
web/src/hooks/useIncrementalCount.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/** How many items of a long list to render, growing as the user gets near the end.
|
||||
|
||||
Committing every card of a 340-album search result inside one keystroke is what made typing
|
||||
feel stuck, and it is work for cards nobody has scrolled to. So a list renders its first
|
||||
`initial` items and asks for `step` more whenever a sentinel placed after the list comes
|
||||
within a screen of the scroll box's edge, or the keyboard selection reaches the last one
|
||||
rendered. A new `resetKey` (another query, group or category) starts over at `initial`, so
|
||||
a short search never inherits the length of the previous long one.
|
||||
|
||||
Growth runs in a transition: the next keystroke interrupts it rather than waiting for it.
|
||||
*/
|
||||
|
||||
import { startTransition, useCallback, useEffect, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
export function nextCount(count: number, step: number, total: number): number {
|
||||
return Math.min(total, count + step);
|
||||
}
|
||||
|
||||
export function useIncrementalCount(
|
||||
total: number,
|
||||
initial: number,
|
||||
step: number,
|
||||
resetKey: string,
|
||||
root: RefObject<HTMLElement | null>,
|
||||
selected: number,
|
||||
): { visible: number; sentinelRef: (node: HTMLElement | null) => void } {
|
||||
const [state, setState] = useState({ key: resetKey, count: initial });
|
||||
const [sentinel, setSentinel] = useState<HTMLElement | null>(null);
|
||||
|
||||
// Adjusting state during render is React's documented way to reset on a changed input;
|
||||
// an effect would paint one frame of the old, long list first.
|
||||
let count = state.count;
|
||||
if (state.key !== resetKey) {
|
||||
count = initial;
|
||||
setState({ key: resetKey, count });
|
||||
} else if (count < initial) {
|
||||
// The grid measured its real column count after first paint.
|
||||
count = initial;
|
||||
}
|
||||
|
||||
const grow = useCallback(() => {
|
||||
startTransition(() =>
|
||||
setState((previous) => ({ ...previous, count: nextCount(Math.max(previous.count, initial), step, total) })),
|
||||
);
|
||||
}, [initial, step, total]);
|
||||
|
||||
// Recreated on every growth: an observer only reports *changes*, so one that stays
|
||||
// attached to a sentinel that is still on screen would never ask a second time.
|
||||
useEffect(() => {
|
||||
if (!sentinel || count >= total) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) grow();
|
||||
},
|
||||
{ root: root.current, rootMargin: "100% 0px" },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [sentinel, count, total, grow, root]);
|
||||
|
||||
// Arrowing down past the fold: the selected item must exist to be scrolled to.
|
||||
useEffect(() => {
|
||||
if (selected >= count - 1 && count < total) grow();
|
||||
}, [selected, count, total, grow]);
|
||||
|
||||
return { visible: Math.min(count, total), sentinelRef: setSentinel };
|
||||
}
|
||||
Reference in New Issue
Block a user