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:
@@ -39,6 +39,14 @@ npx tsc --noEmit # strict, noUncheckedIndexedAccess
|
||||
Only the pure modules are tested - the search, the keyboard machine and the time
|
||||
formatting. That is where the behaviour lives; the components are mostly layout.
|
||||
|
||||
`src/lib/search.ts` builds its normalized search keys once per library payload
|
||||
(`buildSearchIndex`) rather than per keystroke. It matters more than it sounds: a real
|
||||
collection is 343 albums and 4969 tracks, and `normalize()` does a Unicode NFD
|
||||
decomposition, so track search used to mean five thousand of those before a single
|
||||
comparison - 4 ms per keystroke on a laptop, and this runs on a Pi. The same build also
|
||||
partitions albums by shelf and pre-sorts each shelf's categories, which `App` and
|
||||
`BrowseView` were otherwise deriving separately from the same data.
|
||||
|
||||
## How it is put together
|
||||
|
||||
The split that matters: **what is playing** comes from the backend, **what you are
|
||||
@@ -52,7 +60,8 @@ are local.
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `src/lib/keyboard.ts` | The key map, as a pure function from a keypress to a list of actions. Testable without a DOM. |
|
||||
| `src/lib/search.ts` | Filtering and the three-level browse hierarchy. The whole library is in memory, so this is instant. |
|
||||
| `src/lib/search.ts` | Filtering and the three-level browse hierarchy. The whole library is in memory, so this is instant - see the note on `buildSearchIndex` below. |
|
||||
| `src/lib/lowPower.ts` | The `?pi=1` profile: what gets cheaper, and by how much. |
|
||||
| `src/lib/covers.ts` | Generated cover art from the album's three colours, and the book-spine treatment. |
|
||||
| `src/hooks/usePlayerState.ts` | The websocket, with reconnect-and-reseed. |
|
||||
| `src/hooks/usePlaybackClock.ts` | Interpolates the 2 Hz position onto animation frames. |
|
||||
@@ -84,6 +93,34 @@ pointer. `F1` shows the same list in-app.
|
||||
`SHIFT+arrows` is the one addition to the mockup: plain arrows were already taken, and
|
||||
seeking is the one thing a real player can do that the mockup could not.
|
||||
|
||||
## The Raspberry Pi profile
|
||||
|
||||
`?pi=1` loads the same app with the two per-frame costs cut down. It exists for
|
||||
musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen, where the app was spending
|
||||
about 70% of a core with nothing happening.
|
||||
|
||||
What it changes, all of it in `src/lib/lowPower.ts`:
|
||||
|
||||
| | Full | `?pi=1` |
|
||||
|---|---|---|
|
||||
| Ambient canvas backing store | 1:1 with the screen | half scale, capped at 1280 px |
|
||||
| Ambient canvas frame rate | display refresh | 30 fps |
|
||||
| Bubbles alive at once | unlimited | 60 |
|
||||
| Progress-bar re-renders | every animation frame | 10 per second |
|
||||
| `backdrop-filter` glass blur | on | off - plain translucent instead |
|
||||
| Decorative CSS animation loops | on | off |
|
||||
|
||||
The principle is *make the expensive thing cheaper before switching it off*. The canvas
|
||||
is the app's whole look, so it stays and paints a quarter of the pixels half as often -
|
||||
on a soft gradient behind content that is close to invisible. Only the effects with no
|
||||
cheap version go: a real backdrop blur, and the decorative loops that carry no
|
||||
information. The aquarium pets stay on even here; they are the reward the typing game
|
||||
pays out, and a handful of `transform` writes is not what is slow.
|
||||
|
||||
The flag is read once at module load from the URL and nowhere else - no persistence, no
|
||||
auto-detection. That is what makes "is this the profile or the hardware?" answerable by
|
||||
editing the address bar. The kiosk gets it from `pi_kiosk_url` in the ansible repo.
|
||||
|
||||
## Parent mode
|
||||
|
||||
`?parentMode=1` opens a settings panel - volume limits, the rotary step, button
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type Oklch,
|
||||
} from "../lib/ambience";
|
||||
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
||||
import { AMBIENCE_QUALITY } from "../lib/lowPower";
|
||||
import { groupOf, type Group } from "../lib/search";
|
||||
|
||||
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
|
||||
@@ -247,16 +248,22 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
// can't feed back into the size it just measured (see the comment there). The
|
||||
// clamp is a second line of defence against the same failure mode from anywhere
|
||||
// else: an unclamped size can hit the browser's canvas allocation limit and throw.
|
||||
const MAX_BACKING_STORE_PX = 4096;
|
||||
//
|
||||
// `resolutionScale` below 1 (the `?pi=1` profile) makes the backing store smaller
|
||||
// than the canvas is on screen and lets the browser scale it back up. Everything
|
||||
// in `tick` still works in CSS-pixel units, because the transform is derived from
|
||||
// whatever the backing store ended up being rather than from `dpr`.
|
||||
const quality = AMBIENCE_QUALITY;
|
||||
const resize = () => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dpr = (window.devicePixelRatio || 1) * quality.resolutionScale;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
size.width = rect.width;
|
||||
size.height = rect.height;
|
||||
canvas.width = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.width * dpr)));
|
||||
canvas.height = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.height * dpr)));
|
||||
// Scale factors from the (possibly clamped) backing store, not raw `dpr`, so
|
||||
// drawing in CSS-pixel units still lands correctly even when clamped.
|
||||
const cap = quality.maxBackingStorePx;
|
||||
canvas.width = Math.min(cap, Math.max(1, Math.round(rect.width * dpr)));
|
||||
canvas.height = Math.min(cap, Math.max(1, Math.round(rect.height * dpr)));
|
||||
// Scale factors from the (possibly clamped, possibly downscaled) backing store,
|
||||
// not raw `dpr`, so drawing in CSS-pixel units still lands correctly.
|
||||
const scaleX = canvas.width / Math.max(1, rect.width);
|
||||
const scaleY = canvas.height / Math.max(1, rect.height);
|
||||
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
|
||||
@@ -312,7 +319,16 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
smoothedDrive,
|
||||
);
|
||||
|
||||
// A frame budget, not a timer: rAF still drives everything, this just returns
|
||||
// early until enough time has passed. The 1ms slack stops a 30fps budget from
|
||||
// landing just inside a 60Hz vsync interval and silently halving again to 20.
|
||||
const minFrameMs = quality.maxFps > 0 ? 1000 / quality.maxFps - 1 : 0;
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (now - lastFrame < minFrameMs) {
|
||||
raf = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
|
||||
lastFrame = now;
|
||||
|
||||
@@ -386,6 +402,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
|
||||
while (spawnAccumulator >= 1) {
|
||||
spawnAccumulator -= 1;
|
||||
// The cap drops the *newest* bubble rather than evicting an old one, so a
|
||||
// loud passage thins the field out instead of making it flicker.
|
||||
if (quality.maxBubbles > 0 && bubbles.length >= quality.maxBubbles) break;
|
||||
bubbles.push(spawnBubble(size.width));
|
||||
}
|
||||
}
|
||||
@@ -402,7 +421,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
|
||||
});
|
||||
|
||||
ctx.clearRect(0, 0, size.width, size.height);
|
||||
// No `clearRect`: the gradient below is fully opaque (`oklchString` defaults to
|
||||
// alpha 1) and covers the whole canvas, so clearing first is a second
|
||||
// full-screen write per frame that nothing can ever see.
|
||||
const [top, mid, deep] = current.gradient;
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
|
||||
gradient.addColorStop(0, oklchString(top));
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
unitLabel,
|
||||
} from "../lib/covers";
|
||||
import { clock } from "../lib/format";
|
||||
import type { Category, Group, Results, SongHit } from "../lib/search";
|
||||
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
|
||||
import { categoryMatches, groupOf } from "../lib/search";
|
||||
import {
|
||||
ANIMATE_VIEW_TRANSITIONS,
|
||||
@@ -37,7 +37,7 @@ import { Cover } from "./Cover";
|
||||
interface Props {
|
||||
results: Results;
|
||||
group: Group | null;
|
||||
albums: Album[];
|
||||
index: SearchIndex;
|
||||
mode: "albums" | "tracks";
|
||||
search: string;
|
||||
category: string | null;
|
||||
@@ -76,7 +76,7 @@ const GROUP_SECTION_LABEL: Record<Group, string> = {
|
||||
export function BrowseView({
|
||||
results,
|
||||
group,
|
||||
albums,
|
||||
index,
|
||||
mode,
|
||||
search,
|
||||
category,
|
||||
@@ -114,14 +114,14 @@ export function BrowseView({
|
||||
GROUPS.map((shelfGroup) => ({
|
||||
group: shelfGroup,
|
||||
categories: categoryMatches({
|
||||
albums,
|
||||
index,
|
||||
search: "",
|
||||
mode: "albums",
|
||||
category: null,
|
||||
group: shelfGroup,
|
||||
}),
|
||||
})),
|
||||
[albums],
|
||||
[index],
|
||||
);
|
||||
|
||||
// Keep the selection on screen as the arrow keys walk past the fold.
|
||||
|
||||
@@ -7,6 +7,13 @@ also gives beat-synced animation the frame-accurate clock it will need later.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { PLAYBACK_CLOCK_FPS } from "../lib/lowPower";
|
||||
|
||||
/** A render budget, not a timer - rAF still drives the loop, this only decides which
|
||||
* frames are allowed to push state. The 1ms slack keeps a budget from landing just
|
||||
* inside a vsync interval and silently halving the rate it asked for. */
|
||||
const MIN_RENDER_MS = PLAYBACK_CLOCK_FPS > 0 ? 1000 / PLAYBACK_CLOCK_FPS - 1 : 0;
|
||||
|
||||
export function usePlaybackClock(position: number, playing: boolean): number {
|
||||
const [interpolated, setInterpolated] = useState(position);
|
||||
const anchor = useRef({ position, at: performance.now() });
|
||||
@@ -20,10 +27,13 @@ export function usePlaybackClock(position: number, playing: boolean): number {
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let frame = 0;
|
||||
const tick = () => {
|
||||
let lastRender = 0;
|
||||
const tick = (now: number) => {
|
||||
frame = requestAnimationFrame(tick);
|
||||
if (now - lastRender < MIN_RENDER_MS) return;
|
||||
lastRender = now;
|
||||
const { position: base, at } = anchor.current;
|
||||
setInterpolated(base + (performance.now() - at) / 1000);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
|
||||
@@ -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,
|
||||
|
||||
131
web/src/lib/__tests__/search.test.ts
Normal file
131
web/src/lib/__tests__/search.test.ts
Normal 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
68
web/src/lib/lowPower.ts
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 --
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user