Web frontend: perf panel, cover warmup, incremental browse rendering
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Album } from "../../api/types";
|
||||
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
|
||||
import { handleKey, initialUiState, isRootShelf, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
|
||||
import { buildSearchIndex, normalize, results as computeResults, type Results } from "../search";
|
||||
|
||||
function album(id: string, over: Partial<Album> = {}): Album {
|
||||
@@ -291,7 +291,7 @@ describe("keyboard", () => {
|
||||
|
||||
const searching = { ...inPlay, view: "browse" as const };
|
||||
expect(press("Escape", searching)).toEqual([
|
||||
{ type: "ui", patch: { search: "", selIndex: 0 } },
|
||||
{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } },
|
||||
]);
|
||||
|
||||
const inTracks = { ...searching, search: "" };
|
||||
@@ -471,4 +471,33 @@ describe("selectionAt", () => {
|
||||
const ui: UiState = { ...initialUiState, search: "zzzznothing" };
|
||||
expect(selectionAt(resultsFor(ui), 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the search box on / and closes it with ESC before anything else", () => {
|
||||
expect(press("/")).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { view: "browse", searchOpen: true },
|
||||
});
|
||||
const open = { ...initialUiState, searchOpen: true };
|
||||
expect(press("Escape", open)).toEqual([
|
||||
{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops the root shelves as soon as the search box is up", () => {
|
||||
expect(isRootShelf(initialUiState)).toBe(true);
|
||||
expect(isRootShelf({ ...initialUiState, search: "a" })).toBe(false);
|
||||
expect(isRootShelf({ ...initialUiState, searchOpen: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("flips between album and title search with / while the box is up", () => {
|
||||
const open = { ...initialUiState, searchOpen: true, search: "ab" };
|
||||
expect(press("/", open)).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { mode: "tracks", searchOpen: true, selIndex: 0 },
|
||||
});
|
||||
expect(press("/", { ...open, mode: "tracks" })).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { mode: "albums", searchOpen: true, selIndex: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
55
web/src/lib/__tests__/perfSettings.test.ts
Normal file
55
web/src/lib/__tests__/perfSettings.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
async function load(search: string) {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("location", { search });
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
});
|
||||
return import("../perfSettings");
|
||||
}
|
||||
|
||||
describe("perfSettings", () => {
|
||||
beforeEach(() => store.clear());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("is full-fat without pi, ignoring stored values", async () => {
|
||||
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: false }));
|
||||
const m = await load("");
|
||||
expect(m.PERF_ENABLED).toBe(false);
|
||||
expect(m.PERF).toEqual(m.FULL);
|
||||
});
|
||||
|
||||
it("uses the pi preset under ?pi=1", async () => {
|
||||
const m = await load("?pi=1");
|
||||
expect(m.PERF).toEqual(m.PI);
|
||||
});
|
||||
|
||||
it("merges stored overrides over the preset", async () => {
|
||||
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: true, playbackClockFps: 30 }));
|
||||
const m = await load("?pi=1");
|
||||
expect(m.PERF.ambience).toBe(true);
|
||||
expect(m.PERF.playbackClockFps).toBe(30);
|
||||
expect(m.PERF.cardBlur).toBe(m.PI.cardBlur);
|
||||
});
|
||||
|
||||
it("drops wrong-typed fields and survives corrupt JSON", async () => {
|
||||
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: "yes", ambienceScale: null }));
|
||||
expect((await load("?pi=1")).PERF).toEqual((await load("?pi=1")).PI);
|
||||
store.set("musicmouse.perf.v1", "{nope");
|
||||
const m = await load("?pi=1");
|
||||
expect(m.PERF).toEqual(m.PI);
|
||||
});
|
||||
|
||||
it("round-trips through save and reset", async () => {
|
||||
const m = await load("?pi=1");
|
||||
m.savePerfSettings({ ...m.PI, cardBlur: true });
|
||||
expect((await load("?pi=1")).PERF.cardBlur).toBe(true);
|
||||
m.resetPerfSettings();
|
||||
expect((await load("?pi=1")).PERF.cardBlur).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
import type { Album, Track } from "../../api/types";
|
||||
import {
|
||||
buildSearchIndex,
|
||||
effectiveSearch,
|
||||
listMode,
|
||||
MAX_SONG_HITS,
|
||||
normalize,
|
||||
results as computeResults,
|
||||
@@ -111,14 +113,14 @@ describe("songMatches", () => {
|
||||
expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps an empty query, which would otherwise be the whole library", () => {
|
||||
it("caps a query that matches most of the library", () => {
|
||||
const many = Array.from({ length: 60 }, (_, i) =>
|
||||
album(`m${i}`, { tracks: [track(`Lied ${i}`)] }),
|
||||
);
|
||||
const index = buildSearchIndex(many);
|
||||
const hits = computeResults({
|
||||
index,
|
||||
search: "",
|
||||
search: "lied",
|
||||
mode: "tracks",
|
||||
group: null,
|
||||
category: null,
|
||||
@@ -129,3 +131,28 @@ describe("songMatches", () => {
|
||||
expect(hits.at(-1)!.title).toBe(`Lied ${MAX_SONG_HITS - 1}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minimum query length", () => {
|
||||
it("ignores a single letter and anything that normalizes to one", () => {
|
||||
expect(effectiveSearch("e")).toBe("");
|
||||
expect(effectiveSearch(" é ")).toBe("");
|
||||
expect(effectiveSearch("ab")).toBe("ab");
|
||||
expect(effectiveSearch("a b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("leaves the screen alone for one letter and filters from two", () => {
|
||||
const index = buildSearchIndex([album("1"), album("2")]);
|
||||
const base = { index, mode: "albums" as const, group: "music" as Group, category: null };
|
||||
expect(listMode({ ...base, search: "a" })).toBe("categories");
|
||||
expect(computeResults({ ...base, search: "a" }).albums).toHaveLength(0);
|
||||
expect(listMode({ ...base, search: "al" })).toBe("albums");
|
||||
expect(computeResults({ ...base, search: "al" }).albums).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows no titles until two letters are typed", () => {
|
||||
const base = { index: INDEX, mode: "tracks" as const, group: null, category: null };
|
||||
expect(computeResults({ ...base, search: "" }).songs).toEqual([]);
|
||||
expect(computeResults({ ...base, search: "v" }).songs).toEqual([]);
|
||||
expect(computeResults({ ...base, search: "va" }).songs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { Album, AlbumKind, PlayerState } from "../api/types";
|
||||
import { PERF } from "./perfSettings";
|
||||
import { GROUP_HUE } from "./theme";
|
||||
|
||||
export const isBook = (album: Album): boolean => album.kind === "book";
|
||||
@@ -37,15 +38,18 @@ function stripes(album: Album, width: number): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** A book spine: page edges on the right, a darker board on the left. */
|
||||
/** A book spine: page edges on the right, a darker board on the left.
|
||||
*
|
||||
* Stops are sRGB hex on purpose - see the note on `.stage` in app.css: an oklch stop
|
||||
* makes Gecko interpolate in Oklab on the CPU at every display-list build, and these
|
||||
* four gradients sit under every book card. The page edges are oklch(97% 0.02 55) and
|
||||
* oklch(90% 0.03 55) (`GROUP_HUE.audiobooks`), the ridges oklch(98% 0 0 / .35). */
|
||||
export function spine(album: Album): string {
|
||||
const [primary, secondary] = colours(album);
|
||||
const hue = GROUP_HUE.audiobooks;
|
||||
return [
|
||||
`linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 ${hue}) 95% 97.5%,` +
|
||||
` oklch(90% 0.03 ${hue}) 97.5% 100%)`,
|
||||
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
|
||||
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
|
||||
"linear-gradient(90deg, transparent 0 95%, #fff2e9 95% 97.5%, #efd9cc 97.5% 100%)",
|
||||
"linear-gradient(90deg, transparent 0 3.5%, #f8f8f859 3.5% 4.3%," +
|
||||
" transparent 4.3% 7%, #f8f8f859 7% 7.8%, transparent 7.8%)",
|
||||
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
|
||||
`linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`,
|
||||
].join(",");
|
||||
@@ -55,6 +59,17 @@ export function coverBackground(album: Album, stripeWidth: number): string {
|
||||
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
|
||||
}
|
||||
|
||||
/** What sits under a cover that has real art. The art is opaque, so the stripes or the
|
||||
* four-gradient spine would be painted for nothing - on a 340-card grid that is real
|
||||
* display-list work. A flat primary colour is all a still-loading image needs; a book
|
||||
* keeps one cheap gradient for the page edges its clip leaves showing on the right. */
|
||||
export function coverUnderlay(album: Album): string {
|
||||
const [primary] = colours(album);
|
||||
return isBook(album)
|
||||
? `linear-gradient(90deg, ${primary} 0 95%, #fff2e9 95% 97.5%, #efd9cc 97.5% 100%)`
|
||||
: primary;
|
||||
}
|
||||
|
||||
/** The card's own tint: warm paper for books, cool glass for music. Amber
|
||||
* (`GROUP_HUE.audiobooks`) rather than a duller yellow-brown, so it reads as rich
|
||||
* rather than washed-out. */
|
||||
@@ -63,8 +78,16 @@ export const cardBackground = (album: Album): string =>
|
||||
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
||||
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`;
|
||||
|
||||
/** One blurred shadow per card, up to ~340 of them: dropped under `?pi=1`, where a plain
|
||||
* translucent card reads fine and the shadow is a per-card blur render task. Books keep
|
||||
* their two inset "board" bands, which are cheap and are what make them look like books. */
|
||||
export const cardShadow = (album: Album): string =>
|
||||
isBook(album)
|
||||
!PERF.cardShadows
|
||||
? isBook(album)
|
||||
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
||||
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
||||
: "none"
|
||||
: isBook(album)
|
||||
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
||||
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
||||
`0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)`
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { Group, Mode, Results } from "./search";
|
||||
|
||||
export interface UiState {
|
||||
search: string;
|
||||
/** `/` was pressed: show the search box before the first letter arrives. */
|
||||
searchOpen: boolean;
|
||||
mode: Mode;
|
||||
/** `null` is the bare root screen (three shelves); otherwise which one is open. */
|
||||
group: Group | null;
|
||||
@@ -41,6 +43,7 @@ export interface UiState {
|
||||
|
||||
export const initialUiState: UiState = {
|
||||
search: "",
|
||||
searchOpen: false,
|
||||
mode: "albums",
|
||||
group: null,
|
||||
category: null,
|
||||
@@ -179,7 +182,9 @@ function moveSelection(state: UiState, results: Results, dx: number, dy: number)
|
||||
* should be one step too, not two, regardless of whether a category was reached that
|
||||
* way or by opening the group first. Always safe to call. */
|
||||
export function browseBackActions(state: UiState): Action[] {
|
||||
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
||||
if (state.search || state.searchOpen) {
|
||||
return [{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } }];
|
||||
}
|
||||
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
||||
return [{ type: "ui", patch: { group: null, category: null, selIndex: 0 } }];
|
||||
}
|
||||
@@ -201,9 +206,28 @@ function escape(state: UiState): Action[] {
|
||||
return browseBackActions(state);
|
||||
}
|
||||
|
||||
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */
|
||||
/** Whether the search box is on screen: something typed, `/` pressed, or track search. */
|
||||
export function searchBoxShown(state: UiState): boolean {
|
||||
return state.search.length > 0 || state.searchOpen || state.mode === "tracks";
|
||||
}
|
||||
|
||||
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. The
|
||||
* shelves go away the moment the search box appears, before there is a query to filter by. */
|
||||
export function isRootShelf(state: UiState): boolean {
|
||||
return state.group === null && !state.search && state.mode === "albums" && state.category === null;
|
||||
return state.group === null && !searchBoxShown(state) && state.category === null;
|
||||
}
|
||||
|
||||
/** Album search <-> title search, keeping whatever has been typed. `searchOpen` keeps the
|
||||
* box up when the switch lands on albums with nothing typed, where it would otherwise
|
||||
* vanish and bring the shelves back. */
|
||||
export function toggleModeActions(state: UiState): Action[] {
|
||||
return [
|
||||
{ type: "pop", freq: 460 },
|
||||
{
|
||||
type: "ui",
|
||||
patch: { mode: state.mode === "tracks" ? "albums" : "tracks", searchOpen: true, selIndex: 0 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export interface KeyEvent {
|
||||
@@ -340,7 +364,9 @@ export function handleKey(
|
||||
];
|
||||
}
|
||||
case "/":
|
||||
return [{ type: "ui", patch: { view: "browse" } }];
|
||||
// With the box already up, `/` flips what it searches; otherwise it opens it.
|
||||
if (browsing && searchBoxShown(state)) return toggleModeActions(state);
|
||||
return [{ type: "ui", patch: { view: "browse", searchOpen: true } }];
|
||||
|
||||
case "ArrowRight":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
||||
|
||||
@@ -44,15 +44,11 @@
|
||||
* answerable by editing the address bar.
|
||||
*/
|
||||
|
||||
/** True when the page was loaded with `?pi=1`. */
|
||||
export const LOW_POWER = readLowPowerFlag();
|
||||
import { PERF } from "./perfSettings";
|
||||
|
||||
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";
|
||||
}
|
||||
// Superseded as a single switch: every knob below and in theme.ts now comes from
|
||||
// `PERF` (lib/perfSettings.ts), which `?pi=1` seeds and the settings dialog overrides.
|
||||
// This header is kept for the measurements it records.
|
||||
|
||||
export interface AmbienceQuality {
|
||||
/** Multiplies the canvas backing store relative to its on-screen size, before
|
||||
@@ -80,9 +76,12 @@ export interface AmbienceQuality {
|
||||
* canvas off there entirely. Kept, and kept accurate, because it is the setting that
|
||||
* matters the moment anyone turns the canvas back on for a weak device - the half-scale
|
||||
* backing store is what took it from 53.5% to 25%. */
|
||||
export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
|
||||
? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 }
|
||||
: { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 };
|
||||
export const AMBIENCE_QUALITY: AmbienceQuality = {
|
||||
resolutionScale: PERF.ambienceScale,
|
||||
maxBackingStorePx: PERF.ambienceMaxPx,
|
||||
maxFps: PERF.ambienceMaxFps,
|
||||
maxBubbles: PERF.ambienceMaxBubbles,
|
||||
};
|
||||
|
||||
/** 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.
|
||||
@@ -96,4 +95,4 @@ export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
|
||||
*
|
||||
* Only bites while something is playing, so it is not in the idle table above.
|
||||
*/
|
||||
export const PLAYBACK_CLOCK_FPS = LOW_POWER ? 10 : 0;
|
||||
export const PLAYBACK_CLOCK_FPS = PERF.playbackClockFps;
|
||||
|
||||
147
web/src/lib/perfSettings.ts
Normal file
147
web/src/lib/perfSettings.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/** Per-knob performance settings, the granular successor to the old all-or-nothing `?pi=1`.
|
||||
*
|
||||
* `?pi=1` still switches the panel on, but no longer flips everything at once: it selects
|
||||
* the `PI` preset as the starting point, and each knob can then be overridden from the
|
||||
* settings dialog (`PerfPanel`) and is stored in this browser's localStorage. Without
|
||||
* `?pi=1` the stored values are ignored and the full-fat `FULL` preset applies, so a
|
||||
* laptop opening the same page always gets the full version - which is what makes "is this
|
||||
* the profile or the hardware?" answerable by editing the address bar.
|
||||
*
|
||||
* Read once at module load. The knobs feed module-level constants (`SHOW_AMBIENCE`, ...)
|
||||
* and the service worker registration, so applying a change means a page reload.
|
||||
*
|
||||
* `SHOW_GLASS_BLUR` is deliberately not a knob: on the Pi it measured five times *worse*
|
||||
* with the blur off. See the table in lib/lowPower.ts.
|
||||
*/
|
||||
|
||||
export interface PerfSettings {
|
||||
/** Fade/slide animation when entering a group/category or opening the album modal. */
|
||||
viewTransitions: boolean;
|
||||
/** The play view's animated canvas background (gradient + bubbles). */
|
||||
ambience: boolean;
|
||||
/** Real backdrop blur on each repeated glass card/row. */
|
||||
cardBlur: boolean;
|
||||
/** Decorative CSS loops: the room page's bubbles and the dolphin mascot. */
|
||||
decorativeAnimations: boolean;
|
||||
/** Drop shadows under covers in the album cards. */
|
||||
cardShadows: boolean;
|
||||
/** Drop shadows under tiles in the browse grid. */
|
||||
tileShadows: boolean;
|
||||
/** Typing game: pets move (off = placed but held still). */
|
||||
petAnimations: boolean;
|
||||
/** Typing game: rising bubbles. */
|
||||
tippenBubbles: boolean;
|
||||
/** Typing game: fade/slide view transitions. */
|
||||
tippenViewTransitions: boolean;
|
||||
/** Register the service worker (off also unregisters an existing one). */
|
||||
serviceWorker: boolean;
|
||||
/** Pre-decode covers in the background. */
|
||||
coverWarmup: boolean;
|
||||
/** Max playback-position renders per second. 0 = every animation frame. */
|
||||
playbackClockFps: number;
|
||||
/** Ambient canvas backing-store scale relative to its on-screen size. */
|
||||
ambienceScale: number;
|
||||
/** Ceiling on either ambient canvas backing-store axis, in px. */
|
||||
ambienceMaxPx: number;
|
||||
/** Ambient canvas frame-rate cap. 0 = whatever the display offers. */
|
||||
ambienceMaxFps: number;
|
||||
/** Ceiling on ambient bubbles alive at once. 0 = unlimited. */
|
||||
ambienceMaxBubbles: number;
|
||||
}
|
||||
|
||||
/** Today's behaviour without `?pi=1`. */
|
||||
export const FULL: PerfSettings = {
|
||||
viewTransitions: true,
|
||||
ambience: true,
|
||||
cardBlur: true,
|
||||
decorativeAnimations: true,
|
||||
cardShadows: true,
|
||||
tileShadows: true,
|
||||
petAnimations: true,
|
||||
tippenBubbles: true,
|
||||
tippenViewTransitions: true,
|
||||
serviceWorker: true,
|
||||
coverWarmup: false,
|
||||
playbackClockFps: 0,
|
||||
ambienceScale: 1,
|
||||
ambienceMaxPx: 4096,
|
||||
ambienceMaxFps: 0,
|
||||
ambienceMaxBubbles: 0,
|
||||
};
|
||||
|
||||
/** Today's behaviour with `?pi=1`. */
|
||||
export const PI: PerfSettings = {
|
||||
viewTransitions: false,
|
||||
ambience: false,
|
||||
cardBlur: false,
|
||||
decorativeAnimations: false,
|
||||
cardShadows: false,
|
||||
tileShadows: false,
|
||||
petAnimations: false,
|
||||
tippenBubbles: false,
|
||||
tippenViewTransitions: false,
|
||||
serviceWorker: false,
|
||||
coverWarmup: true,
|
||||
playbackClockFps: 10,
|
||||
ambienceScale: 0.5,
|
||||
ambienceMaxPx: 1280,
|
||||
ambienceMaxFps: 30,
|
||||
ambienceMaxBubbles: 60,
|
||||
};
|
||||
|
||||
export const STORAGE_KEY = "musicmouse.perf.v1";
|
||||
|
||||
/** True when the page was loaded with `?pi=1`. */
|
||||
export const PERF_ENABLED = readPiFlag();
|
||||
|
||||
function readPiFlag(): 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";
|
||||
}
|
||||
|
||||
/** Keep only stored fields whose type matches the preset's; anything else is dropped. */
|
||||
function sanitize(raw: unknown): Partial<PerfSettings> {
|
||||
if (typeof raw !== "object" || raw === null) return {};
|
||||
const out: Record<string, boolean | number> = {};
|
||||
for (const [key, def] of Object.entries(PI)) {
|
||||
const value = (raw as Record<string, unknown>)[key];
|
||||
if (typeof value === typeof def && (typeof value !== "number" || Number.isFinite(value))) {
|
||||
out[key] = value as boolean | number;
|
||||
}
|
||||
}
|
||||
return out as Partial<PerfSettings>;
|
||||
}
|
||||
|
||||
export function loadStoredPerf(): Partial<PerfSettings> {
|
||||
try {
|
||||
const text = localStorage.getItem(STORAGE_KEY);
|
||||
return text ? sanitize(JSON.parse(text)) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPerfSettings(): PerfSettings {
|
||||
if (!PERF_ENABLED) return { ...FULL };
|
||||
return { ...PI, ...loadStoredPerf() };
|
||||
}
|
||||
|
||||
export function savePerfSettings(settings: PerfSettings): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// Storage blocked: the dialog still reloads, it just comes back with the preset.
|
||||
}
|
||||
}
|
||||
|
||||
export function resetPerfSettings(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export const PERF: PerfSettings = loadPerfSettings();
|
||||
@@ -53,6 +53,16 @@ function searchWords(value: string): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** One letter matches most of the library, which is a screen of noise and not a
|
||||
* search - the first character is the user finding their footing, not asking. */
|
||||
export const MIN_SEARCH_CHARS = 2;
|
||||
|
||||
/** The query that actually filters: `""` until there are at least `MIN_SEARCH_CHARS`
|
||||
* letters to go on, so the screen stays where it is while the first one is typed. */
|
||||
export function effectiveSearch(value: string): string {
|
||||
return searchWords(value).join("").length >= MIN_SEARCH_CHARS ? value : "";
|
||||
}
|
||||
|
||||
/** Every word has to show up somewhere in the haystack, in any order. */
|
||||
function matchesWords(haystack: string, words: string[]): boolean {
|
||||
return words.every((word) => haystack.includes(word));
|
||||
@@ -185,7 +195,7 @@ function trackPool(query: BrowseQuery): TrackEntry[] {
|
||||
/** Which of the three lists the browse view is showing. */
|
||||
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
||||
if (query.mode === "tracks") return "tracks";
|
||||
if (query.search || query.category) return "albums";
|
||||
if (effectiveSearch(query.search) || query.category) return "albums";
|
||||
return "categories";
|
||||
}
|
||||
|
||||
@@ -198,7 +208,7 @@ export function categoryMatches(query: BrowseQuery): Category[] {
|
||||
|
||||
export function albumMatches(query: BrowseQuery): Album[] {
|
||||
if (query.mode === "tracks") return [];
|
||||
const words = searchWords(query.search);
|
||||
const words = searchWords(effectiveSearch(query.search));
|
||||
if (!words.length && !query.category) return [];
|
||||
let candidates = albumPool(query);
|
||||
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
|
||||
@@ -206,15 +216,17 @@ export function albumMatches(query: BrowseQuery): Album[] {
|
||||
return candidates.map((e) => e.album);
|
||||
}
|
||||
|
||||
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
|
||||
/** Capped, because a query like "e" over 900 podcast episodes is not a useful screen. */
|
||||
export const MAX_SONG_HITS = 40;
|
||||
|
||||
export function songMatches(query: BrowseQuery): SongHit[] {
|
||||
if (query.mode !== "tracks") return [];
|
||||
const words = searchWords(query.search);
|
||||
const words = searchWords(effectiveSearch(query.search));
|
||||
// Titles are five thousand rows: nothing until there is a query, not the first 40.
|
||||
if (!words.length) return [];
|
||||
const hits: SongHit[] = [];
|
||||
for (const track of trackPool(query)) {
|
||||
if (words.length && !matchesWords(track.haystack, words)) continue;
|
||||
if (!matchesWords(track.haystack, words)) continue;
|
||||
hits.push({
|
||||
album: track.album,
|
||||
index: track.index,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { LOW_POWER } from "./lowPower";
|
||||
import { PERF } from "./perfSettings";
|
||||
import type { Group } from "./search";
|
||||
import type { UiState } from "./keyboard";
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ROW_SPACING = 25;
|
||||
/** Fade the album/category grid in (with a slight upward slide) when a group or
|
||||
* category is entered, and the album modal in when it opens. One animation per
|
||||
* container, not per card, so cost stays flat regardless of grid size. */
|
||||
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
|
||||
export const ANIMATE_VIEW_TRANSITIONS = PERF.viewTransitions;
|
||||
|
||||
/** Frost the glass panels/cards/rows with a real backdrop blur.
|
||||
*
|
||||
@@ -54,7 +54,7 @@ export const SHOW_GLASS_BLUR = true;
|
||||
* loop that repaints the full viewport is a floor you cannot get under while it runs at
|
||||
* all, and on a Pi 4 that floor is too high. Nothing else in the app needs a frame loop,
|
||||
* so with this off the browser has nothing to do between one keypress and the next. */
|
||||
export const SHOW_AMBIENCE = !LOW_POWER;
|
||||
export const SHOW_AMBIENCE = PERF.ambience;
|
||||
|
||||
/** Frost the *repeated* glass surfaces - every album card in the grid, every row in a
|
||||
* track list - as opposed to the handful of panels wrapped around them.
|
||||
@@ -65,14 +65,14 @@ export const SHOW_AMBIENCE = !LOW_POWER;
|
||||
* card is one live backdrop copy out of three hundred sitting directly over the
|
||||
* animated canvas, so every canvas frame re-blurs all of them. Measured on the Pi with
|
||||
* a full album grid on screen, Chromium: see lib/lowPower.ts. */
|
||||
export const SHOW_CARD_BLUR = !LOW_POWER;
|
||||
export const SHOW_CARD_BLUR = PERF.cardBlur;
|
||||
|
||||
/** Run the purely decorative CSS animation loops: the room page's bubble field and
|
||||
* the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
|
||||
* noise, but "nothing on this screen moves by itself" is a property worth having
|
||||
* outright rather than a sum of small wins - a compositor with no animation to service
|
||||
* has nothing to wake up for. */
|
||||
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER;
|
||||
export const SHOW_DECORATIVE_ANIMATIONS = PERF.decorativeAnimations;
|
||||
|
||||
// ------------------------------------------------------------------ colors --
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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";
|
||||
import { PERF } from "../perfSettings";
|
||||
|
||||
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
|
||||
export const HUE = 175;
|
||||
@@ -17,7 +17,7 @@ export const SHOW_GLASS_BLUR = true;
|
||||
|
||||
/** The decorative rising bubbles behind everything. A dozen elements on a CSS transform
|
||||
* loop - off under `?pi=1`, like every other loop in the app. */
|
||||
export const SHOW_BUBBLES = !LOW_POWER;
|
||||
export const SHOW_BUBBLES = PERF.tippenBubbles;
|
||||
|
||||
/** 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.
|
||||
@@ -29,7 +29,7 @@ export const SHOW_BUBBLES = !LOW_POWER;
|
||||
export const SHOW_AQUARIUM_CREATURES = true;
|
||||
|
||||
/** Fade screens in on entry. */
|
||||
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
|
||||
export const ANIMATE_VIEW_TRANSITIONS = PERF.tippenViewTransitions;
|
||||
|
||||
/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
|
||||
* as each one is mastered - the scaffold that removes itself, which is the whole point
|
||||
|
||||
Reference in New Issue
Block a user