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
|
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.
|
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
|
## How it is put together
|
||||||
|
|
||||||
The split that matters: **what is playing** comes from the backend, **what you are
|
The split that matters: **what is playing** comes from the backend, **what you are
|
||||||
@@ -52,7 +60,8 @@ are local.
|
|||||||
| Path | What it is |
|
| 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/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/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/usePlayerState.ts` | The websocket, with reconnect-and-reseed. |
|
||||||
| `src/hooks/usePlaybackClock.ts` | Interpolates the 2 Hz position onto animation frames. |
|
| `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
|
`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.
|
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
|
## Parent mode
|
||||||
|
|
||||||
`?parentMode=1` opens a settings panel - volume limits, the rotary step, button
|
`?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 { playPop } from "./lib/pop";
|
||||||
import { targetForAlbum } from "./lib/remote";
|
import { targetForAlbum } from "./lib/remote";
|
||||||
import type { Group, Results, SongHit } from "./lib/search";
|
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";
|
import { SHOW_AMBIENCE, SHOW_GLASS_BLUR } from "./lib/theme";
|
||||||
|
|
||||||
/** How long an armed "A" waits for the digit that completes the shortcut. */
|
/** 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 }));
|
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
|
||||||
}, [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(
|
const results: Results = useMemo(
|
||||||
() =>
|
() =>
|
||||||
computeResults({
|
computeResults({
|
||||||
albums: library.albums,
|
index,
|
||||||
search: ui.search,
|
search: ui.search,
|
||||||
mode: ui.mode,
|
mode: ui.mode,
|
||||||
group: ui.group,
|
group: ui.group,
|
||||||
category: ui.category,
|
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
|
// The bare root's three shelves, by category key only: the keyboard handler needs
|
||||||
// (see BrowseView) - the keyboard handler needs the same lists, by key only, to know
|
// these to know how far Ctrl+hjkl can move within a row and which category ENTER
|
||||||
// how far Ctrl+hjkl can move within a row and which category ENTER opens.
|
// opens. BrowseView renders the same shelves from the same precomputed lists.
|
||||||
const rootShelves = useMemo(
|
const rootShelves = useMemo(
|
||||||
() =>
|
() =>
|
||||||
GROUP_ORDER.map((group) =>
|
GROUP_ORDER.map((group) =>
|
||||||
categoryMatches({
|
categoryMatches({
|
||||||
albums: library.albums,
|
index,
|
||||||
search: "",
|
search: "",
|
||||||
mode: "albums",
|
mode: "albums",
|
||||||
category: null,
|
category: null,
|
||||||
group,
|
group,
|
||||||
}).map((entry) => entry.key),
|
}).map((entry) => entry.key),
|
||||||
),
|
),
|
||||||
[library.albums],
|
[index],
|
||||||
);
|
);
|
||||||
|
|
||||||
const byId = useMemo(
|
const byId = useMemo(
|
||||||
@@ -432,7 +437,7 @@ export function App() {
|
|||||||
<BrowseView
|
<BrowseView
|
||||||
results={results}
|
results={results}
|
||||||
group={ui.group}
|
group={ui.group}
|
||||||
albums={library.albums}
|
index={index}
|
||||||
mode={ui.mode}
|
mode={ui.mode}
|
||||||
search={ui.search}
|
search={ui.search}
|
||||||
category={ui.category}
|
category={ui.category}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
type Oklch,
|
type Oklch,
|
||||||
} from "../lib/ambience";
|
} from "../lib/ambience";
|
||||||
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
||||||
|
import { AMBIENCE_QUALITY } from "../lib/lowPower";
|
||||||
import { groupOf, type Group } from "../lib/search";
|
import { groupOf, type Group } from "../lib/search";
|
||||||
|
|
||||||
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
|
/** 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
|
// 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
|
// 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.
|
// 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 resize = () => {
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = (window.devicePixelRatio || 1) * quality.resolutionScale;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
size.width = rect.width;
|
size.width = rect.width;
|
||||||
size.height = rect.height;
|
size.height = rect.height;
|
||||||
canvas.width = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.width * dpr)));
|
const cap = quality.maxBackingStorePx;
|
||||||
canvas.height = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.height * dpr)));
|
canvas.width = Math.min(cap, Math.max(1, Math.round(rect.width * dpr)));
|
||||||
// Scale factors from the (possibly clamped) backing store, not raw `dpr`, so
|
canvas.height = Math.min(cap, Math.max(1, Math.round(rect.height * dpr)));
|
||||||
// drawing in CSS-pixel units still lands correctly even when clamped.
|
// 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 scaleX = canvas.width / Math.max(1, rect.width);
|
||||||
const scaleY = canvas.height / Math.max(1, rect.height);
|
const scaleY = canvas.height / Math.max(1, rect.height);
|
||||||
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
|
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
|
||||||
@@ -312,7 +319,16 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
smoothedDrive,
|
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) => {
|
const tick = (now: number) => {
|
||||||
|
if (now - lastFrame < minFrameMs) {
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
|
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
|
||||||
lastFrame = now;
|
lastFrame = now;
|
||||||
|
|
||||||
@@ -386,6 +402,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
|
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
|
||||||
while (spawnAccumulator >= 1) {
|
while (spawnAccumulator >= 1) {
|
||||||
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));
|
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;
|
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 [top, mid, deep] = current.gradient;
|
||||||
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
|
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
|
||||||
gradient.addColorStop(0, oklchString(top));
|
gradient.addColorStop(0, oklchString(top));
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
unitLabel,
|
unitLabel,
|
||||||
} from "../lib/covers";
|
} from "../lib/covers";
|
||||||
import { clock } from "../lib/format";
|
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 { categoryMatches, groupOf } from "../lib/search";
|
||||||
import {
|
import {
|
||||||
ANIMATE_VIEW_TRANSITIONS,
|
ANIMATE_VIEW_TRANSITIONS,
|
||||||
@@ -37,7 +37,7 @@ import { Cover } from "./Cover";
|
|||||||
interface Props {
|
interface Props {
|
||||||
results: Results;
|
results: Results;
|
||||||
group: Group | null;
|
group: Group | null;
|
||||||
albums: Album[];
|
index: SearchIndex;
|
||||||
mode: "albums" | "tracks";
|
mode: "albums" | "tracks";
|
||||||
search: string;
|
search: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
@@ -76,7 +76,7 @@ const GROUP_SECTION_LABEL: Record<Group, string> = {
|
|||||||
export function BrowseView({
|
export function BrowseView({
|
||||||
results,
|
results,
|
||||||
group,
|
group,
|
||||||
albums,
|
index,
|
||||||
mode,
|
mode,
|
||||||
search,
|
search,
|
||||||
category,
|
category,
|
||||||
@@ -114,14 +114,14 @@ export function BrowseView({
|
|||||||
GROUPS.map((shelfGroup) => ({
|
GROUPS.map((shelfGroup) => ({
|
||||||
group: shelfGroup,
|
group: shelfGroup,
|
||||||
categories: categoryMatches({
|
categories: categoryMatches({
|
||||||
albums,
|
index,
|
||||||
search: "",
|
search: "",
|
||||||
mode: "albums",
|
mode: "albums",
|
||||||
category: null,
|
category: null,
|
||||||
group: shelfGroup,
|
group: shelfGroup,
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
[albums],
|
[index],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the selection on screen as the arrow keys walk past the fold.
|
// 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 { 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 {
|
export function usePlaybackClock(position: number, playing: boolean): number {
|
||||||
const [interpolated, setInterpolated] = useState(position);
|
const [interpolated, setInterpolated] = useState(position);
|
||||||
const anchor = useRef({ position, at: performance.now() });
|
const anchor = useRef({ position, at: performance.now() });
|
||||||
@@ -20,10 +27,13 @@ export function usePlaybackClock(position: number, playing: boolean): number {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playing) return;
|
if (!playing) return;
|
||||||
let frame = 0;
|
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;
|
const { position: base, at } = anchor.current;
|
||||||
setInterpolated(base + (performance.now() - at) / 1000);
|
setInterpolated(base + (performance.now() - at) / 1000);
|
||||||
frame = requestAnimationFrame(tick);
|
|
||||||
};
|
};
|
||||||
frame = requestAnimationFrame(tick);
|
frame = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import type { Album } from "../../api/types";
|
import type { Album } from "../../api/types";
|
||||||
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
|
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 {
|
function album(id: string, over: Partial<Album> = {}): Album {
|
||||||
return {
|
return {
|
||||||
@@ -40,9 +40,11 @@ const key = (k: string, over: Partial<Parameters<typeof handleKey>[0]> = {}) =>
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const INDEX = buildSearchIndex(ALBUMS);
|
||||||
|
|
||||||
const resultsFor = (ui: UiState) =>
|
const resultsFor = (ui: UiState) =>
|
||||||
computeResults({
|
computeResults({
|
||||||
albums: ALBUMS,
|
index: INDEX,
|
||||||
search: ui.search,
|
search: ui.search,
|
||||||
mode: ui.mode,
|
mode: ui.mode,
|
||||||
group: ui.group,
|
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
|
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.
|
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";
|
import type { Album } from "../api/types";
|
||||||
@@ -58,19 +70,118 @@ export function inGroup(album: Album, group: Group): boolean {
|
|||||||
return groupOf(album) === group;
|
return groupOf(album) === group;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `null` means unrestricted - typing at the root searches every group at once. */
|
// ------------------------------------------------------------------- index --
|
||||||
export function pool(albums: Album[], group: Group | null): Album[] {
|
|
||||||
return group === null ? albums : albums.filter((album) => inGroup(album, group));
|
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[];
|
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;
|
search: string;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
group: Group | null;
|
group: Group | null;
|
||||||
category: string | 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. */
|
/** Which of the three lists the browse view is showing. */
|
||||||
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
||||||
if (query.mode === "tracks") return "tracks";
|
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
|
// `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.
|
// flat category list - each shelf calls this again with its own group filled in.
|
||||||
if (listMode(query) !== "categories" || query.group === null) return [];
|
if (listMode(query) !== "categories" || query.group === null) return [];
|
||||||
const map = new Map<string, Category>();
|
return query.index.categoriesByGroup[query.group];
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function albumMatches(query: BrowseQuery): Album[] {
|
export function albumMatches(query: BrowseQuery): Album[] {
|
||||||
if (query.mode === "tracks") return [];
|
if (query.mode === "tracks") return [];
|
||||||
const words = searchWords(query.search);
|
const words = searchWords(query.search);
|
||||||
let candidates = pool(query.albums, query.group);
|
|
||||||
if (!words.length && !query.category) return [];
|
if (!words.length && !query.category) return [];
|
||||||
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
|
let candidates = albumPool(query);
|
||||||
if (!words.length) return candidates;
|
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
|
||||||
return candidates.filter((a) => matchesWords(normalize(a.title + a.artist), words));
|
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. */
|
/** 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 [];
|
if (query.mode !== "tracks") return [];
|
||||||
const words = searchWords(query.search);
|
const words = searchWords(query.search);
|
||||||
const hits: SongHit[] = [];
|
const hits: SongHit[] = [];
|
||||||
for (const album of pool(query.albums, query.group)) {
|
for (const track of trackPool(query)) {
|
||||||
album.tracks.forEach((track, index) => {
|
if (words.length && !matchesWords(track.haystack, words)) continue;
|
||||||
// A locked track has no real title to search by - it shows as a question mark
|
hits.push({
|
||||||
// wherever it appears, so it has nothing useful to match here either.
|
album: track.album,
|
||||||
if (track.locked) return;
|
index: track.index,
|
||||||
if (!words.length || matchesWords(normalize(track.title), words)) {
|
title: track.title,
|
||||||
hits.push({ album, index, title: track.title, duration: track.duration });
|
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;
|
if (hits.length >= MAX_SONG_HITS) break;
|
||||||
}
|
}
|
||||||
return hits.slice(0, MAX_SONG_HITS);
|
return hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Results {
|
export interface Results {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import { LOW_POWER } from "./lowPower";
|
||||||
import type { Group } from "./search";
|
import type { Group } from "./search";
|
||||||
import type { UiState } from "./keyboard";
|
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
|
/** 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
|
* 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
|
* per element, repeated for every album card in the unvirtualized grid - and unlike
|
||||||
* this off on weak hardware (e.g. an old Raspberry Pi) to fall back to a plain
|
* the canvas there is no cheaper version of it to fall back to, so `?pi=1` switches
|
||||||
* translucent background with no blur. */
|
* it off outright: a plain translucent background, no blur. This is the one visible
|
||||||
export const SHOW_GLASS_BLUR = true;
|
* cost of the Pi profile. */
|
||||||
|
export const SHOW_GLASS_BLUR = !LOW_POWER;
|
||||||
|
|
||||||
/** Render the play view's animated canvas background (gradient + bubbles, both
|
/** 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
|
* 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;
|
export const SHOW_AMBIENCE = true;
|
||||||
|
|
||||||
/** Run the purely decorative CSS animation loops: the room page's bubble field and
|
/** 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
|
* the dolphin mascot's bob/swim. Individually cheap (opacity/transform only), but
|
||||||
* free to cut on weak hardware. */
|
* a couple of dozen elements animating forever still means a repaint every frame on
|
||||||
export const SHOW_DECORATIVE_ANIMATIONS = true;
|
* a machine compositing in software, and they carry no information. */
|
||||||
|
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER;
|
||||||
|
|
||||||
// ------------------------------------------------------------------ colors --
|
// ------------------------------------------------------------------ colors --
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,24 @@
|
|||||||
* through components. The hue lives in styles/app.css because CSS is where it is used;
|
* 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. */
|
* 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. */
|
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
|
||||||
export const HUE = 175;
|
export const HUE = 175;
|
||||||
|
|
||||||
/** Frost the glass panels with a real backdrop blur. Expensive on weak GPUs. */
|
/** Frost the glass panels with a real backdrop blur. Expensive on weak GPUs, and the
|
||||||
export const SHOW_GLASS_BLUR = true;
|
* 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. */
|
/** The decorative rising bubbles behind everything. A dozen elements animating forever,
|
||||||
export const SHOW_BUBBLES = true;
|
* 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
|
/** 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;
|
export const SHOW_AQUARIUM_CREATURES = true;
|
||||||
|
|
||||||
/** Fade screens in on entry. */
|
/** Fade screens in on entry. */
|
||||||
|
|||||||
Reference in New Issue
Block a user