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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user