Add a ?pi=1 profile, and stop re-deriving search keys per keystroke

Two separate costs, both measured on musicdolphin (Pi 4, 1920x1080 kiosk), where the
app was burning ~70% of a core with nothing happening on screen.

Per-frame work. The ambient canvas repaints a full-screen gradient plus a particle
field every frame, and `usePlaybackClock` pushes a React setState per animation frame
into both PlayView and PlayerBar for the whole length of a track. `?pi=1` (lib/
lowPower.ts) makes those cheaper rather than switching them off: the canvas paints a
quarter of the pixels at 30fps with a bubble cap, and the clock renders ten times a
second - a progress bar advances one pixel every few hundred ms and its label has
one-second resolution, so nothing on screen can tell. Only the effects with no cheap
version actually go: the backdrop-filter glass blur and the decorative CSS loops.
Also drops a redundant full-canvas clearRect that the opaque gradient always covered.

Search. normalize() runs a Unicode NFD decomposition, and albumMatches/songMatches
called it on every album title and every track title on every keystroke - 4969 of them
for a track search, whose answer cannot change until the library does. buildSearchIndex
does it once per library payload; a keystroke is now String.includes over strings that
already exist. On the real library that is 33ms -> 3.4ms for an eight-letter track
query on a laptop, and this runs on a Pi. The same index partitions albums by shelf and
pre-sorts each shelf's categories, which App and BrowseView were deriving separately
from the same data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 13:15:23 +02:00
parent a7fb56c9fe
commit cc3db44c4e
11 changed files with 457 additions and 67 deletions

View File

@@ -34,7 +34,7 @@ import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf
import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote";
import type { Group, Results, SongHit } from "./lib/search";
import { categoryMatches, groupOf, results as computeResults } from "./lib/search";
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
import { SHOW_AMBIENCE, SHOW_GLASS_BLUR } from "./lib/theme";
/** How long an armed "A" waits for the digit that completes the shortcut. */
@@ -92,33 +92,38 @@ export function App() {
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]);
const results: Results = useMemo(
() =>
computeResults({
albums: library.albums,
index,
search: ui.search,
mode: ui.mode,
group: ui.group,
category: ui.category,
}),
[library.albums, ui.search, ui.mode, ui.group, ui.category],
[index, ui.search, ui.mode, ui.group, ui.category],
);
// The bare root's three shelves each compute their own categories independently
// (see BrowseView) - the keyboard handler needs the same lists, by key only, to know
// how far Ctrl+hjkl can move within a row and which category ENTER opens.
// 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({
albums: library.albums,
index,
search: "",
mode: "albums",
category: null,
group,
}).map((entry) => entry.key),
),
[library.albums],
[index],
);
const byId = useMemo(
@@ -432,7 +437,7 @@ export function App() {
<BrowseView
results={results}
group={ui.group}
albums={library.albums}
index={index}
mode={ui.mode}
search={ui.search}
category={ui.category}