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

@@ -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

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}

View File

@@ -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));

View File

@@ -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.

View File

@@ -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);

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. */