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

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { Album } from "../../api/types";
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
import { normalize, results as computeResults, type Results } from "../search";
import { buildSearchIndex, normalize, results as computeResults, type Results } from "../search";
function album(id: string, over: Partial<Album> = {}): Album {
return {
@@ -40,9 +40,11 @@ const key = (k: string, over: Partial<Parameters<typeof handleKey>[0]> = {}) =>
...over,
});
const INDEX = buildSearchIndex(ALBUMS);
const resultsFor = (ui: UiState) =>
computeResults({
albums: ALBUMS,
index: INDEX,
search: ui.search,
mode: ui.mode,
group: ui.group,

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import type { Album, Track } from "../../api/types";
import {
buildSearchIndex,
MAX_SONG_HITS,
normalize,
results as computeResults,
type Group,
} from "../search";
function track(title: string, over: Partial<Track> = {}): Track {
return { title, duration: 60, analysis: null, locked: false, unlock_hint: null, ...over };
}
function album(id: string, over: Partial<Album> = {}): Album {
return {
id,
section: "Musik",
kind: "music",
title: `Album ${id}`,
artist: "Kinderparty",
series: null,
figure: null,
category: "Kinderparty",
colors: ["#111111", "#222222", "#333333"],
has_cover: false,
duration: 120,
locked: false,
tracks: [track(`Lied ${id}`)],
...over,
};
}
const ALBUMS: Album[] = [
album("a", { title: "Conni lernt Rad fahren", artist: "Conni", kind: "book", category: "Conni" }),
album("b", { title: "Käpt'n Krabbe", artist: "Rolf", category: "Rolf" }),
album("c", {
section: "Kinderpodcasts",
title: "GEOlino Spezial",
artist: "WDR",
category: "GEOlino",
tracks: [track("Der Vater der Chemie"), track("Geheime Folge", { locked: true })],
}),
];
const INDEX = buildSearchIndex(ALBUMS);
const query = (over: Partial<Parameters<typeof computeResults>[0]> = {}) =>
computeResults({ index: INDEX, search: "", mode: "albums", group: null, category: null, ...over });
describe("buildSearchIndex", () => {
it("buckets albums onto the three shelves by section and kind", () => {
const groups = Object.fromEntries(
(["music", "audiobooks", "podcasts"] as Group[]).map((g) => [
g,
INDEX.byGroup[g].map((e) => e.album.id),
]),
);
expect(groups).toEqual({ music: ["b"], audiobooks: ["a"], podcasts: ["c"] });
});
it("sorts each shelf's categories, and only its own", () => {
expect(INDEX.categoriesByGroup.music.map((c) => c.key)).toEqual(["Rolf"]);
expect(INDEX.categoriesByGroup.audiobooks.map((c) => c.key)).toEqual(["Conni"]);
expect(INDEX.categoriesByGroup.podcasts.map((c) => c.key)).toEqual(["GEOlino"]);
});
it("leaves locked tracks out entirely - they have no title to match on", () => {
expect(INDEX.tracks.map((t) => t.title)).not.toContain("Geheime Folge");
expect(INDEX.tracks).toHaveLength(3);
});
it("stores the same normalized keys the matchers used to compute per keystroke", () => {
const conni = INDEX.entries.find((e) => e.album.id === "a")!;
expect(conni.haystack).toBe(normalize("Conni lernt Rad fahrenConni"));
});
});
describe("albumMatches", () => {
it("finds words in any order, across title and artist", () => {
expect(query({ search: "conni rad" }).albums.map((a) => a.id)).toEqual(["a"]);
expect(query({ search: "rad conni" }).albums.map((a) => a.id)).toEqual(["a"]);
});
it("ignores punctuation and diacritics", () => {
expect(query({ search: "kaptn" }).albums.map((a) => a.id)).toEqual(["b"]);
});
it("stays inside the shelf once one is entered", () => {
expect(query({ search: "conni", group: "music" }).albums).toEqual([]);
expect(query({ search: "conni", group: "audiobooks" }).albums.map((a) => a.id)).toEqual(["a"]);
});
it("lists a whole category with no search text", () => {
expect(query({ group: "music", category: "Rolf" }).albums.map((a) => a.id)).toEqual(["b"]);
});
it("shows nothing at the bare root - that screen is the three shelves", () => {
expect(query().albums).toEqual([]);
});
});
describe("songMatches", () => {
it("searches track titles, not album titles", () => {
const hits = query({ mode: "tracks", search: "vater" }).songs;
expect(hits.map((h) => [h.album.id, h.index, h.title])).toEqual([["c", 0, "Der Vater der Chemie"]]);
});
it("never returns a locked track", () => {
expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]);
});
it("caps an empty query, which would otherwise be the whole library", () => {
const many = Array.from({ length: 60 }, (_, i) =>
album(`m${i}`, { tracks: [track(`Lied ${i}`)] }),
);
const index = buildSearchIndex(many);
const hits = computeResults({
index,
search: "",
mode: "tracks",
group: null,
category: null,
}).songs;
expect(hits).toHaveLength(MAX_SONG_HITS);
// Album order, then track order - the cap takes a prefix, it does not reshuffle.
expect(hits[0]!.title).toBe("Lied 0");
expect(hits.at(-1)!.title).toBe(`Lied ${MAX_SONG_HITS - 1}`);
});
});

68
web/src/lib/lowPower.ts Normal file
View File

@@ -0,0 +1,68 @@
/** `?pi=1` - the same app, cut down to what a Raspberry Pi 4 can actually paint.
*
* The device this exists for is musicdolphin: a Pi 4 driving a 1920x1080 monitor
* through a Firefox kiosk window. The GPU is fine there (`V3D 4.2`, accelerated), but
* Firefox on Linux composites in the parent process, and the two things this app does
* every frame - repaint a full-screen canvas, and blur a live backdrop copy behind
* every card - were between them eating about 70% of a core with nothing happening on
* screen. Everything below is aimed at those two costs and nothing else.
*
* The rule the profile follows: *make the expensive things cheaper before switching
* any of them off*. The ambient canvas stays - it is the app's whole look - it just
* paints a quarter of the pixels half as often, which on a soft gradient behind
* content is close to invisible. Only the effects with no cheap version (a real
* backdrop blur, the decorative animation loops) actually go.
*
* Read once at module load, from the URL and nowhere else: no persistence, no
* auto-detection. The kiosk points Firefox at `http://localhost:8080/?pi=1` (see the
* `pi_kiosk_url` host var in the ansible repo) and a laptop opening the same page gets
* the full-fat version, which is what makes "is this the Pi profile or the hardware?"
* answerable by editing the address bar.
*/
/** True when the page was loaded with `?pi=1`. */
export const LOW_POWER = readLowPowerFlag();
function readLowPowerFlag(): boolean {
// `location` is absent under vitest's default node environment.
if (typeof location === "undefined") return false;
const value = new URLSearchParams(location.search).get("pi");
return value === "1" || value === "true";
}
export interface AmbienceQuality {
/** Multiplies the canvas backing store relative to its on-screen size, before
* `devicePixelRatio`. At 0.5 the loop fills a quarter of the pixels and the browser
* scales the result up; on a blurred-by-nature gradient with soft translucent
* bubbles over it, that upscale is hard to see and the fill cost is the whole
* point - `fillRect` over the gradient is proportional to pixels and happens every
* single frame. */
resolutionScale: number;
/** Hard ceiling on either backing-store axis. Also a guard against a runaway
* measurement hitting the browser's canvas allocation limit. */
maxBackingStorePx: number;
/** Frames per second the loop will paint. 0 means "whatever the display offers".
* The gradient eases over ~2s and the bubbles drift; neither has anything to say
* at 60fps that it can't say at 30. */
maxFps: number;
/** Ceiling on bubbles alive at once. 0 means unlimited. Spawn rate is driven by the
* music, so a loud track on a big screen can otherwise pile up more circles per
* frame than this device can draw. */
maxBubbles: number;
}
export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 }
: { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 };
/** How many times a second the interpolated playback position may push a new React
* render. 0 means "every animation frame", which is what it has always done.
*
* `usePlaybackClock` exists to stop the progress bar stepping twice a second, and it
* does that with a `setState` per animation frame - so `PlayView` and `PlayerBar`
* each re-render sixty times a second for the whole length of a track. A progress bar
* a thousand-odd pixels wide advances one pixel every few *hundred* milliseconds on a
* three-minute track, and the time label beside it only has one-second resolution, so
* ten updates a second is already more than either can show.
*/
export const PLAYBACK_CLOCK_FPS = LOW_POWER ? 10 : 0;

View File

@@ -2,6 +2,18 @@
The whole index arrives in one response, so type-to-search has no round trip and feels
instant - which is the point of a keyboard-first UI. Ported from the design mockup.
The cost that matters here is per keystroke, not per library load. A real collection is
343 albums and 4969 tracks, and the naive version of this module re-derived its search
keys from scratch on every single one of them every time a letter was typed:
`normalize()` runs `toLowerCase` + Unicode NFD decomposition + two regex passes, so
track search meant five thousand NFD normalizations before a single comparison. That is
work whose answer cannot change until the library itself changes.
So it is done once, in `buildSearchIndex`, and a keystroke is reduced to
`String.includes` over strings that already exist. The same build also partitions the
albums by group and pre-computes each group's sorted category list - which `App` and
`BrowseView` were otherwise both deriving, independently, from the same albums.
*/
import type { Album } from "../api/types";
@@ -58,19 +70,118 @@ export function inGroup(album: Album, group: Group): boolean {
return groupOf(album) === group;
}
/** `null` means unrestricted - typing at the root searches every group at once. */
export function pool(albums: Album[], group: Group | null): Album[] {
return group === null ? albums : albums.filter((album) => inGroup(album, group));
// ------------------------------------------------------------------- index --
const GROUPS: readonly Group[] = ["music", "audiobooks", "podcasts"];
export interface AlbumEntry {
album: Album;
group: Group;
/** `normalize(title + artist)`, as `albumMatches` used to compute per keystroke. */
haystack: string;
}
export interface BrowseQuery {
export interface TrackEntry {
album: Album;
/** Index within `album.tracks`, which is what `SongHit` and playback both want. */
index: number;
title: string;
duration: number;
group: Group;
haystack: string;
}
/** Everything a search needs, derived once per library payload. */
export interface SearchIndex {
/** The albums exactly as the backend sent them, for callers that want the plain list. */
albums: Album[];
entries: AlbumEntry[];
byGroup: Record<Group, AlbumEntry[]>;
/** Each group's categories, already grouped and sorted. Same array identity on every
* read, so a `useMemo` downstream of it stays stable. */
categoriesByGroup: Record<Group, Category[]>;
/** Unlocked tracks only, album order then track order. A locked track shows as a
* question mark wherever it appears, so it has no title to match on and is left out
* of the index entirely rather than skipped at match time. A reward being earned
* reloads the library, which rebuilds this. */
tracks: TrackEntry[];
tracksByGroup: Record<Group, TrackEntry[]>;
}
const emptyByGroup = <T,>(): Record<Group, T[]> => ({ music: [], audiobooks: [], podcasts: [] });
export function buildSearchIndex(albums: Album[]): SearchIndex {
const entries: AlbumEntry[] = [];
const byGroup = emptyByGroup<AlbumEntry>();
const tracks: TrackEntry[] = [];
const tracksByGroup = emptyByGroup<TrackEntry>();
const categoryMaps: Record<Group, Map<string, Category>> = {
music: new Map(),
audiobooks: new Map(),
podcasts: new Map(),
};
for (const album of albums) {
const group = groupOf(album);
const entry: AlbumEntry = { album, group, haystack: normalize(album.title + album.artist) };
entries.push(entry);
byGroup[group].push(entry);
const categories = categoryMaps[group];
let category = categories.get(album.category);
if (!category) {
category = { key: album.category, albums: [] };
categories.set(album.category, category);
}
category.albums.push(album);
album.tracks.forEach((track, index) => {
if (track.locked) return;
const trackEntry: TrackEntry = {
album,
index,
title: track.title,
duration: track.duration,
group,
haystack: normalize(track.title),
};
tracks.push(trackEntry);
tracksByGroup[group].push(trackEntry);
});
}
const categoriesByGroup = emptyByGroup<Category>();
for (const group of GROUPS) {
categoriesByGroup[group] = [...categoryMaps[group].values()].sort((a, b) =>
a.key.localeCompare(b.key, "de"),
);
}
return { albums, entries, byGroup, categoriesByGroup, tracks, tracksByGroup };
}
/** For the moment before the library has loaded, and for tests that don't need one. */
export const EMPTY_INDEX: SearchIndex = buildSearchIndex([]);
// ----------------------------------------------------------------- queries --
export interface BrowseQuery {
index: SearchIndex;
search: string;
mode: Mode;
group: Group | null;
category: string | null;
}
/** `null` group means unrestricted - typing at the root searches every group at once. */
function albumPool(query: BrowseQuery): AlbumEntry[] {
return query.group === null ? query.index.entries : query.index.byGroup[query.group];
}
function trackPool(query: BrowseQuery): TrackEntry[] {
return query.group === null ? query.index.tracks : query.index.tracksByGroup[query.group];
}
/** Which of the three lists the browse view is showing. */
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
if (query.mode === "tracks") return "tracks";
@@ -82,27 +193,17 @@ export function categoryMatches(query: BrowseQuery): Category[] {
// `group === null` is the bare root screen, rendered as three shelves instead of a
// flat category list - each shelf calls this again with its own group filled in.
if (listMode(query) !== "categories" || query.group === null) return [];
const map = new Map<string, Category>();
for (const album of pool(query.albums, query.group)) {
const key = album.category;
let entry = map.get(key);
if (!entry) {
entry = { key, albums: [] };
map.set(key, entry);
}
entry.albums.push(album);
}
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, "de"));
return query.index.categoriesByGroup[query.group];
}
export function albumMatches(query: BrowseQuery): Album[] {
if (query.mode === "tracks") return [];
const words = searchWords(query.search);
let candidates = pool(query.albums, query.group);
if (!words.length && !query.category) return [];
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
if (!words.length) return candidates;
return candidates.filter((a) => matchesWords(normalize(a.title + a.artist), words));
let candidates = albumPool(query);
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
if (words.length) candidates = candidates.filter((e) => matchesWords(e.haystack, words));
return candidates.map((e) => e.album);
}
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
@@ -112,18 +213,19 @@ export function songMatches(query: BrowseQuery): SongHit[] {
if (query.mode !== "tracks") return [];
const words = searchWords(query.search);
const hits: SongHit[] = [];
for (const album of pool(query.albums, query.group)) {
album.tracks.forEach((track, index) => {
// A locked track has no real title to search by - it shows as a question mark
// wherever it appears, so it has nothing useful to match here either.
if (track.locked) return;
if (!words.length || matchesWords(normalize(track.title), words)) {
hits.push({ album, index, title: track.title, duration: track.duration });
}
for (const track of trackPool(query)) {
if (words.length && !matchesWords(track.haystack, words)) continue;
hits.push({
album: track.album,
index: track.index,
title: track.title,
duration: track.duration,
});
// Stopping at the cap rather than filling every hit and slicing is what keeps an
// empty query in tracks mode from walking all 4969 tracks for 40 rows.
if (hits.length >= MAX_SONG_HITS) break;
}
return hits.slice(0, MAX_SONG_HITS);
return hits;
}
export interface Results {

View File

@@ -11,6 +11,7 @@
import type { CSSProperties } from "react";
import { LOW_POWER } from "./lowPower";
import type { Group } from "./search";
import type { UiState } from "./keyboard";
@@ -36,20 +37,26 @@ export const ANIMATE_VIEW_TRANSITIONS = true;
/** Frost the glass panels/cards/rows with a real backdrop blur. `backdrop-filter` is
* one of the most GPU-expensive CSS effects in the app - a live backdrop copy+blur
* per element, repeated for every album card in the unvirtualized grid - so turn
* this off on weak hardware (e.g. an old Raspberry Pi) to fall back to a plain
* translucent background with no blur. */
export const SHOW_GLASS_BLUR = true;
* per element, repeated for every album card in the unvirtualized grid - and unlike
* the canvas there is no cheaper version of it to fall back to, so `?pi=1` switches
* it off outright: a plain translucent background, no blur. This is the one visible
* cost of the Pi profile. */
export const SHOW_GLASS_BLUR = !LOW_POWER;
/** Render the play view's animated canvas background (gradient + bubbles, both
* redrawn every frame at 60fps). Off falls back to `.stage`'s own static CSS
* gradient, which is still underneath the canvas either way. */
* gradient, which is still underneath the canvas either way.
*
* Deliberately still on under `?pi=1`: it is the app's whole look, and
* `AMBIENCE_QUALITY` in lib/lowPower.ts makes it affordable (quarter of the pixels,
* half the frames) rather than making it go away. */
export const SHOW_AMBIENCE = true;
/** Run the purely decorative CSS animation loops: the room page's bubble field and
* the dolphin mascot's bob/swim. Individually cheap (opacity/transform only) but
* free to cut on weak hardware. */
export const SHOW_DECORATIVE_ANIMATIONS = true;
* the dolphin mascot's bob/swim. Individually cheap (opacity/transform only), but
* a couple of dozen elements animating forever still means a repaint every frame on
* a machine compositing in software, and they carry no information. */
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER;
// ------------------------------------------------------------------ colors --

View File

@@ -4,17 +4,24 @@
* through components. The hue lives in styles/app.css because CSS is where it is used;
* it is repeated here only for the canvas, which cannot read a custom property. */
import { LOW_POWER } from "../lowPower";
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
export const HUE = 175;
/** Frost the glass panels with a real backdrop blur. Expensive on weak GPUs. */
export const SHOW_GLASS_BLUR = true;
/** Frost the glass panels with a real backdrop blur. Expensive on weak GPUs, and the
* on-screen keyboard frosts every single key - off under `?pi=1`, like the music app's
* matching toggle. */
export const SHOW_GLASS_BLUR = !LOW_POWER;
/** The decorative rising bubbles behind everything. */
export const SHOW_BUBBLES = true;
/** The decorative rising bubbles behind everything. A dozen elements animating forever,
* carrying no information, so `?pi=1` drops them. */
export const SHOW_BUBBLES = !LOW_POWER;
/** The earned pets swimming behind every screen. The reward that is always in view - off
* only to rule it out when chasing a performance problem. */
* only to rule it out when chasing a performance problem. Kept on even under `?pi=1`: a
* handful of images moved by writing `transform`, and taking away what she earned to save
* a few frames is the wrong trade. */
export const SHOW_AQUARIUM_CREATURES = true;
/** Fade screens in on entry. */