diff --git a/web/README.md b/web/README.md index 4c5632d..68da901 100644 --- a/web/README.md +++ b/web/README.md @@ -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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 789ef69..8900f47 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { { - 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)); diff --git a/web/src/components/BrowseView.tsx b/web/src/components/BrowseView.tsx index 3dfacf3..90c58bd 100644 --- a/web/src/components/BrowseView.tsx +++ b/web/src/components/BrowseView.tsx @@ -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 = { 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. diff --git a/web/src/hooks/usePlaybackClock.ts b/web/src/hooks/usePlaybackClock.ts index 38c0ee1..ac18547 100644 --- a/web/src/hooks/usePlaybackClock.ts +++ b/web/src/hooks/usePlaybackClock.ts @@ -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); diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts index cc7eebc..c97e14e 100644 --- a/web/src/lib/__tests__/keyboard.test.ts +++ b/web/src/lib/__tests__/keyboard.test.ts @@ -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 { return { @@ -40,9 +40,11 @@ const key = (k: string, over: Partial[0]> = {}) => ...over, }); +const INDEX = buildSearchIndex(ALBUMS); + const resultsFor = (ui: UiState) => computeResults({ - albums: ALBUMS, + index: INDEX, search: ui.search, mode: ui.mode, group: ui.group, diff --git a/web/src/lib/__tests__/search.test.ts b/web/src/lib/__tests__/search.test.ts new file mode 100644 index 0000000..9198352 --- /dev/null +++ b/web/src/lib/__tests__/search.test.ts @@ -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 { + return { title, duration: 60, analysis: null, locked: false, unlock_hint: null, ...over }; +} + +function album(id: string, over: Partial = {}): 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[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}`); + }); +}); diff --git a/web/src/lib/lowPower.ts b/web/src/lib/lowPower.ts new file mode 100644 index 0000000..506fa6d --- /dev/null +++ b/web/src/lib/lowPower.ts @@ -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; diff --git a/web/src/lib/search.ts b/web/src/lib/search.ts index 795878d..0584a80 100644 --- a/web/src/lib/search.ts +++ b/web/src/lib/search.ts @@ -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; + /** Each group's categories, already grouped and sorted. Same array identity on every + * read, so a `useMemo` downstream of it stays stable. */ + categoriesByGroup: Record; + /** 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; +} + +const emptyByGroup = (): Record => ({ music: [], audiobooks: [], podcasts: [] }); + +export function buildSearchIndex(albums: Album[]): SearchIndex { + const entries: AlbumEntry[] = []; + const byGroup = emptyByGroup(); + const tracks: TrackEntry[] = []; + const tracksByGroup = emptyByGroup(); + const categoryMaps: Record> = { + 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(); + 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(); - 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 { diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts index f16bba6..b528c06 100644 --- a/web/src/lib/theme.ts +++ b/web/src/lib/theme.ts @@ -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 -- diff --git a/web/src/lib/tippen/theme.ts b/web/src/lib/tippen/theme.ts index 001deef..1941b22 100644 --- a/web/src/lib/tippen/theme.ts +++ b/web/src/lib/tippen/theme.ts @@ -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. */