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