From 2db4368fc91bb10980136fab755832759cef7398 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Mon, 21 Sep 2026 11:42:33 +0200 Subject: [PATCH] Web frontend: perf panel, cover warmup, incremental browse rendering Co-Authored-By: Claude Sonnet 5 --- web/src/App.tsx | 92 +++- web/src/api/__tests__/client.test.ts | 20 + web/src/api/client.ts | 18 +- web/src/api/types.ts | 2 + web/src/components/BrowseView.tsx | 467 +++++++++++------- web/src/components/Cover.tsx | 16 +- web/src/components/PerfPanel.tsx | 158 ++++++ .../components/tippen/AquariumCreatures.tsx | 4 +- web/src/components/tippen/Stage.tsx | 4 +- web/src/hooks/useCoverWarmup.ts | 55 +++ web/src/hooks/useEvent.ts | 17 + web/src/hooks/useIncrementalCount.ts | 68 +++ web/src/lib/__tests__/keyboard.test.ts | 33 +- web/src/lib/__tests__/perfSettings.test.ts | 55 +++ web/src/lib/__tests__/search.test.ts | 31 +- web/src/lib/covers.ts | 37 +- web/src/lib/keyboard.ts | 34 +- web/src/lib/lowPower.ts | 23 +- web/src/lib/perfSettings.ts | 147 ++++++ web/src/lib/search.ts | 22 +- web/src/lib/theme.ts | 10 +- web/src/lib/tippen/theme.ts | 6 +- web/src/main.tsx | 13 + web/src/styles/app.css | 101 +++- 24 files changed, 1178 insertions(+), 255 deletions(-) create mode 100644 web/src/api/__tests__/client.test.ts create mode 100644 web/src/components/PerfPanel.tsx create mode 100644 web/src/hooks/useCoverWarmup.ts create mode 100644 web/src/hooks/useEvent.ts create mode 100644 web/src/hooks/useIncrementalCount.ts create mode 100644 web/src/lib/__tests__/perfSettings.test.ts create mode 100644 web/src/lib/perfSettings.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index d63cd46..cf99c0b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,9 +7,13 @@ */ import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Suspense, lazy, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { api } from "./api/client"; +import { PERF_ENABLED, PERF } from "./lib/perfSettings"; +import { PerfPanel } from "./components/PerfPanel"; +import { useCoverWarmup } from "./hooks/useCoverWarmup"; +import { useEvent } from "./hooks/useEvent"; import type { Album, HaConfig, RemoteMapping, RemoteSlotInput } from "./api/types"; import { AlbumModal } from "./components/AlbumModal"; import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience"; @@ -21,22 +25,27 @@ import { ParentPanel } from "./components/ParentPanel"; import { PlayerBar } from "./components/PlayerBar"; import { PlayView } from "./components/PlayView"; import { RemoteAssignPopup } from "./components/RemoteAssignPopup"; -import { RoomView } from "./components/RoomView"; import { TabRail } from "./components/TabRail"; -import { TippenApp } from "./components/TippenApp"; import { useGridColumns } from "./hooks/useGridColumns"; import { useLibrary } from "./hooks/useLibrary"; import { usePlayerState } from "./hooks/usePlayerState"; import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables"; import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables"; import type { Action, UiState } from "./lib/keyboard"; -import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf } from "./lib/keyboard"; +import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard"; import { playPop } from "./lib/pop"; import { targetForAlbum } from "./lib/remote"; import type { Group, Results, SongHit } from "./lib/search"; import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search"; import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme"; +// The room page and the typing game are each a tab away from the player and together a +// large share of the bundle; the kiosk parses and compiles only what it opens. +const RoomView = lazy(() => import("./components/RoomView").then((m) => ({ default: m.RoomView }))); +const TippenApp = lazy(() => + import("./components/TippenApp").then((m) => ({ default: m.TippenApp })), +); + /** How long an armed "A" waits for the digit that completes the shortcut. */ const ASSIGN_PENDING_TIMEOUT_MS = 4000; @@ -58,6 +67,9 @@ export function App() { const [parentMode, setParentMode] = useState( () => new URLSearchParams(location.search).get("parentMode") === "1", ); + const [perfOpen, setPerfOpen] = useState( + () => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1", + ); const [debugDynamicUI] = useState( () => new URLSearchParams(location.search).get("debugDynamicUI") === "1", ); @@ -97,16 +109,24 @@ export function App() { // only reloads when the backend says a rescan finished, so this is genuinely rare. const index = useMemo(() => buildSearchIndex(library.albums), [library.albums]); + // Kiosk only: fetch and decode every card thumbnail up front, in idle time. + useCoverWarmup(library.albums, PERF.coverWarmup); + + // Typing updates `ui.search` at once and the search box with it; the results follow a + // deferred copy, so a render for an older query is dropped when the next letter lands + // instead of being finished first. + const deferredSearch = useDeferredValue(ui.search); + const results: Results = useMemo( () => computeResults({ index, - search: ui.search, + search: deferredSearch, mode: ui.mode, group: ui.group, category: ui.category, }), - [index, ui.search, ui.mode, ui.group, ui.category], + [index, deferredSearch, ui.mode, ui.group, ui.category], ); // The bare root's three shelves, by category key only: the keyboard handler needs @@ -351,17 +371,17 @@ export function App() { return () => clearTimeout(timer); }, [assignStatus]); - const onEnterGroup = (group: Group, category: string | null) => { + const onEnterGroup = useEvent((group: Group, category: string | null) => { playPop(category ? 440 : 380); - setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 })); - }; + setUi((previous) => ({ ...previous, group, category, search: "", searchOpen: false, selIndex: 0 })); + }); - const onCategory = (key: string | null) => { + const onCategory = useEvent((key: string | null) => { if (key) playPop(440); setUi((previous) => ({ ...previous, category: key, selIndex: 0 })); - }; + }); - const onOpenAlbum = (album: Album, navIndex: number) => { + const onOpenAlbum = useEvent((album: Album, navIndex: number) => { // A podcast episode is a single track behaving like an audiobook of one chapter - // there's nothing a metadata popup would add, so it just starts playing. if (groupOf(album) === "podcasts") { @@ -371,14 +391,14 @@ export function App() { } playPop(420); setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex, modalTrackIndex: 0 })); - }; + }); /** Clicking the title text (as opposed to the cover) starts the album right away - * the same thing ENTER does from the keyboard - instead of opening the track list. */ - const onPlayAlbum = (album: Album, navIndex: number) => { + const onPlayAlbum = useEvent((album: Album, navIndex: number) => { setUi((previous) => ({ ...previous, selIndex: navIndex })); play(album.id, 0); - }; + }); const onOpenCurrentAlbum = () => { if (!currentAlbum) return; @@ -392,7 +412,7 @@ export function App() { })); }; - const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index); + const onPlaySong = useEvent((hit: SongHit) => play(hit.album.id, hit.index)); const onNext = useCallback(() => run([{ type: "next" }]), [run]); const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]); @@ -443,7 +463,11 @@ export function App() { group={ui.group} index={index} mode={ui.mode} - search={ui.search} + search={deferredSearch} + typed={ui.search} + searchOpen={ui.searchOpen} + onToggleMode={() => run(toggleModeActions(ui))} + columns={columns} category={ui.category} selIndex={ui.selIndex} shelfRow={ui.shelfRow} @@ -506,11 +530,13 @@ export function App() { )} - {ui.page === "room" && haConfig && } + + {ui.page === "room" && haConfig && } - {ui.page === "typing" && ( - setUi((previous) => ({ ...previous, page: "music" }))} /> - )} + {ui.page === "typing" && ( + setUi((previous) => ({ ...previous, page: "music" }))} /> + )} + {openAlbum && state && ( setParentMode(false)} />} + {PERF_ENABLED && !perfOpen && ( + + )} + {PERF_ENABLED && perfOpen && setPerfOpen(false)} />} + {(!state || library.loading) && } {debugDynamicUI && debugSnapshot && ( diff --git a/web/src/api/__tests__/client.test.ts b/web/src/api/__tests__/client.test.ts new file mode 100644 index 0000000..9f6e13e --- /dev/null +++ b/web/src/api/__tests__/client.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { coverUrl } from "../client"; + +describe("coverUrl", () => { + it("is the plain full-size URL with no options", () => { + expect(coverUrl("abc")).toBe("/api/albums/abc/cover"); + }); + + it("asks for the thumbnail and carries the cache-busting version", () => { + expect(coverUrl("abc", { size: "thumb", version: 42 })).toBe( + "/api/albums/abc/cover?size=thumb&v=42", + ); + }); + + it("leaves the version off when there is none, so the server revalidates", () => { + expect(coverUrl("abc", { size: "thumb" })).toBe("/api/albums/abc/cover?size=thumb"); + expect(coverUrl("abc", { version: 0 })).toBe("/api/albums/abc/cover"); + }); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 32e4b88..56a4b08 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -122,4 +122,20 @@ export const api = { request("/tippen/runs", { method: "POST", body: JSON.stringify(body) }), }; -export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`; +/** Cover URL. `thumb` is the small file for cards and rows; only the play view needs + * `full`. With a `version` (`Album.cover_v`) the URL names one exact set of bytes and the + * server marks it immutable, so the browser never asks for it twice; without one it + * revalidates on every use. */ +export function coverUrl( + albumId: string, + { size = "full", version }: { size?: "thumb" | "full"; version?: number } = {}, +): string { + const params: string[] = []; + if (size === "thumb") params.push("size=thumb"); + if (version) params.push(`v=${version}`); + return `/api/albums/${albumId}/cover${params.length ? `?${params.join("&")}` : ""}`; +} + +/** Largest `Cover` `size` (CSS px) that is served the thumbnail; the play view's 340 gets + * the full file. Keep in step with `THUMB_COVER_PX` in the backend's `library/cache.py`. */ +export const THUMB_MAX_SIZE = 220; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e200d54..07b6e2d 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -67,6 +67,8 @@ export interface Album { /** Three `#rrggbb`, taken from the cover art or synthesised: primary, secondary, accent. */ colors: string[]; has_cover: boolean; + /** Cache-busting version of the cover file; put it in the cover URL's `v`. */ + cover_v?: number; duration: number; tracks: Track[]; /** Every track is still locked - show a question mark instead of cover art. */ diff --git a/web/src/components/BrowseView.tsx b/web/src/components/BrowseView.tsx index 90c58bd..6cec0dd 100644 --- a/web/src/components/BrowseView.tsx +++ b/web/src/components/BrowseView.tsx @@ -5,7 +5,7 @@ * flat across songs, then categories, then albums so one pair of arrow keys walks * the whole page. */ -import { useEffect, useMemo, useRef } from "react"; +import { memo, useEffect, useMemo, useRef } from "react"; import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; import type { Album } from "../api/types"; @@ -19,7 +19,7 @@ import { } from "../lib/covers"; import { clock } from "../lib/format"; import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search"; -import { categoryMatches, groupOf } from "../lib/search"; +import { categoryMatches, effectiveSearch, groupOf } from "../lib/search"; import { ANIMATE_VIEW_TRANSITIONS, glassTint, @@ -32,6 +32,8 @@ import { SHOW_ROW_TITLES, TAB_RAIL_CLEARANCE, } from "../lib/theme"; +import { useIncrementalCount } from "../hooks/useIncrementalCount"; +import { PERF } from "../lib/perfSettings"; import { Cover } from "./Cover"; interface Props { @@ -39,7 +41,17 @@ interface Props { group: Group | null; index: SearchIndex; mode: "albums" | "tracks"; + /** The query the results below were computed for. Trails `typed` while a render for + * a newer one is still in flight. */ search: string; + /** What is in the search box right now - never behind. */ + typed: string; + /** `/` was pressed, so the box shows while still empty. */ + searchOpen: boolean; + /** The pill in the search box: album search <-> title search. */ + onToggleMode: () => void; + /** Columns in the album grid, so the first paint can be exactly three rows. */ + columns: number; category: string | null; selIndex: number; /** Which of the three root shelves is focused - only meaningful at the bare root, @@ -73,12 +85,16 @@ const GROUP_SECTION_LABEL: Record = { podcasts: "Episoden", }; -export function BrowseView({ +function BrowseViewImpl({ results, group, index, mode, - search, + search: rawSearch, + typed, + searchOpen, + onToggleMode, + columns, category, selIndex, shelfRow, @@ -93,7 +109,12 @@ export function BrowseView({ const scroller = useRef(null); const { songs, categories, albums: albumResults, total } = results; - const isRootShelf = group === null && mode !== "tracks" && !search; + // One letter is not a query yet - see `effectiveSearch`. + const search = effectiveSearch(rawSearch); + + // Gone as soon as the box appears - `typed` and `searchOpen` are never behind, so the + // shelves do not linger for the one letter that is not a query yet. + const isRootShelf = group === null && mode !== "tracks" && !typed && !searchOpen; // The root shelf has no flat `results` of its own (each row computes its own // categories independently) - it's a genuine two-axis layout instead: `shelfRow` @@ -103,11 +124,7 @@ export function BrowseView({ // needs the row half of that. const focusedRow = Math.max(0, Math.min(GROUPS.length - 1, shelfRow)); const focusedCol = Math.max(0, selIndex); - const selected = isRootShelf - ? focusedRow - : total - ? Math.min(selIndex, total - 1) - : -1; + const selected = isRootShelf ? focusedRow : total ? Math.min(selIndex, total - 1) : -1; const shelves = useMemo( () => @@ -138,103 +155,146 @@ export function BrowseView({ } }, [selected]); + // Long lists render a screenful and grow as the user nears the end. The three lists + // share one selection index (songs, then categories, then albums), hence the offsets. + const rowKey = `${group}:${category}:${mode}:${search}`; + const gridInitial = Math.max(1, columns) * 3; + const songWindow = useIncrementalCount(songs.length, 20, 20, rowKey, scroller, selected); + const categoryWindow = useIncrementalCount( + categories.length, + gridInitial, + gridInitial, + rowKey, + scroller, + selected - songs.length, + ); + const albumWindow = useIncrementalCount( + albumResults.length, + gridInitial, + gridInitial, + rowKey, + scroller, + selected - songs.length - categories.length, + ); + if (isRootShelf) { return ( -
- {shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => ( -
onEnterGroup(shelfGroup, null)} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onEnterGroup(shelfGroup, null); - }} - aria-label={`Alle ${GROUP_ICON[shelfGroup]} ${GROUP_LABEL[shelfGroup]} durchsuchen`} - style={{ - marginBottom: ROW_SPACING, - padding: "12px 14px", - cursor: "pointer", - // Once a row has tiles, the focus ring belongs on the tile the keyboard - // is actually on (below) rather than the row around it - this only - // stands in for that when there's nothing to focus. - outline: - focusedRow === index && shelfCategories.length === 0 - ? "4px solid var(--paper)" - : undefined, - outlineOffset: 2, - ...glassTint(GROUP_HUE[shelfGroup]), - }} - > - {(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && ( -
- {SHOW_ROW_ICONS && {GROUP_ICON[shelfGroup]}} - {SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]} - {SHOW_ROW_TITLES && } -
- )} - {shelfCategories.length === 0 ? ( -
- Noch nichts hier -
- ) : ( - - {shelfCategories.map((entry, tileIndex) => ( - { - // Otherwise this bubbles to the panel's own onClick, which would - // open the group root instead of the category just clicked. - event.stopPropagation(); - onEnterGroup(shelfGroup, entry.key); - }} - /> - ))} - - )} -
- ))} -
+ <> + +
+ {shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => ( +
onEnterGroup(shelfGroup, null)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onEnterGroup(shelfGroup, null); + }} + aria-label={`Alle ${GROUP_ICON[shelfGroup]} ${GROUP_LABEL[shelfGroup]} durchsuchen`} + style={{ + marginBottom: ROW_SPACING, + padding: "12px 14px", + cursor: "pointer", + // Once a row has tiles, the focus ring belongs on the tile the keyboard + // is actually on (below) rather than the row around it - this only + // stands in for that when there's nothing to focus. + outline: + focusedRow === index && shelfCategories.length === 0 + ? "4px solid var(--paper)" + : undefined, + outlineOffset: 2, + ...glassTint(GROUP_HUE[shelfGroup]), + }} + > + {(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && ( +
+ {SHOW_ROW_ICONS && {GROUP_ICON[shelfGroup]}} + {SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]} + {SHOW_ROW_TITLES && } +
+ )} + {shelfCategories.length === 0 ? ( +
+ Noch nichts hier +
+ ) : ( + + {shelfCategories.map((entry, tileIndex) => ( + { + // Otherwise this bubbles to the panel's own onClick, which would + // open the group root instead of the category just clicked. + event.stopPropagation(); + onEnterGroup(shelfGroup, entry.key); + }} + /> + ))} + + )} +
+ ))} +
+ ); } - const showSearchBar = search.length > 0 || mode === "tracks"; const countLabel = - mode === "tracks" - ? `${songs.length} Titel gefunden` - : albumResults.length - ? `${albumResults.length} Album${albumResults.length === 1 ? "" : "en"} gefunden` - : "nichts gefunden"; + typed.trim() && !search + ? "noch ein Buchstabe …" + : mode === "tracks" + ? songs.length + ? `${songs.length} Titel gefunden` + : search + ? "nichts gefunden" + : "" + : albumResults.length + ? `${albumResults.length} Album${albumResults.length === 1 ? "" : "en"} gefunden` + : search + ? "nichts gefunden" + : ""; return ( <> @@ -253,50 +313,13 @@ export function BrowseView({ )} - {showSearchBar && ( -
-
- - {mode === "tracks" ? "♪ Titel" : "🔎 Alben"} - - {search ? `"${search}"` : "tippe …"} -
-
- {countLabel} -
-
- )} +
{songs.length > 0 && (
@@ -320,7 +348,7 @@ export function BrowseView({ margin: "0 auto", }} > - {songs.map((hit, index) => ( + {songs.slice(0, songWindow.visible).map((hit, index) => (
-
+
{hit.title}
))}
+ {songWindow.visible < songs.length && ( +
+ )}
)} @@ -384,7 +420,7 @@ export function BrowseView({
{GROUP_CATEGORY_LABEL[group]}
- {categories.map((entry, index) => { + {categories.slice(0, categoryWindow.visible).map((entry, index) => { const navIndex = songs.length + index; return ( + {categoryWindow.visible < categories.length && ( +
+ )}
)} @@ -419,7 +458,7 @@ export function BrowseView({ {GROUP_SECTION_LABEL[group]} )}
- {albumResults.map((album, index) => { + {albumResults.slice(0, albumWindow.visible).map((album, index) => { const navIndex = songs.length + categories.length + index; const podcast = groupOf(album) === "podcasts"; // An audiobook's artist is almost always the same as the character/ @@ -468,7 +507,11 @@ export function BrowseView({
@@ -557,20 +602,34 @@ export function BrowseView({ ); })}
+ {albumWindow.visible < albumResults.length && ( +
+ )}
)} {total === 0 && (
- Nichts gefunden — probier andere Buchstaben! + {search + ? "Nichts gefunden — probier andere Buchstaben!" + : "Tippe mindestens zwei Buchstaben …"}
)} @@ -648,11 +707,10 @@ function ShelfRow({ element.classList.remove("dragging"); if (dragged) { // This was a pan, not a click - swallow the click a tile would otherwise get. - window.addEventListener( - "click", - (clickEvent) => clickEvent.stopPropagation(), - { capture: true, once: true }, - ); + window.addEventListener("click", (clickEvent) => clickEvent.stopPropagation(), { + capture: true, + once: true, + }); } }; window.addEventListener("mousemove", onMove); @@ -682,6 +740,7 @@ function CategoryTile({ }) { const books = entry.albums.filter(isBook).length; const allBooks = books === entry.albums.length; + const allPodcasts = entry.albums.every((a) => groupOf(a) === "podcasts"); const mostlyBooks = books * 2 > entry.albums.length; const shown = entry.albums.slice(0, 4); return ( @@ -695,7 +754,8 @@ function CategoryTile({ ? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)` : `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`, borderRadius: allBooks ? "6px 18px 18px 6px" : "16px", - boxShadow: "0 6px 18px var(--shadow)", + // One blurred shadow per tile: dropped under `?pi=1`, like `cardShadow`. + boxShadow: PERF.tileShadows ? "0 6px 18px var(--shadow)" : "none", }} >
{entry.albums.length}{" "} - {allBooks + {allPodcasts ? entry.albums.length === 1 - ? "Hörbuch" - : "Hörbücher" - : entry.albums.length === 1 - ? "Album" - : "Alben"} + ? "Episode" + : "Episoden" + : allBooks + ? entry.albums.length === 1 + ? "Hörbuch" + : "Hörbücher" + : entry.albums.length === 1 + ? "Album" + : "Alben"}
); } -function SectionTitle({ - children, - centered, -}: { - children: ReactNode; - centered?: boolean; -}) { +function SectionTitle({ children, centered }: { children: ReactNode; centered?: boolean }) { return (
+ {children} ); } + +/** The search field, shown once there is something in it so it is obvious where typing goes. It only + * displays: keys are caught by the window handler in App, which is what lets the whole + * UI be driven from the keyboard without focusing anything first. */ +function SearchBox({ + typed, + mode, + open, + onToggleMode, + note, +}: { + typed: string; + mode: "albums" | "tracks"; + open: boolean; + onToggleMode: () => void; + note: string; +}) { + // Nothing to show until a letter is typed or `/` opens it - and in tracks mode the + // box is also how the screen says which mode it is in. + if (!typed && !open && mode !== "tracks") return null; + return ( +
+
+ {/* mousedown is swallowed so the button never takes focus: a focused button + would also "click" on the Space or Enter the window handler already used. */} + + + {!typed && } + {typed || Tippe zum Suchen …} + {typed && } + +
+ {note &&
{note}
} +
+ ); +} + +/** Memoised: `App` re-renders twice a second during playback (position frames), and + * every prop here is either a primitive, a memoised value or a stable handler, so the + * card grid - up to ~340 cards - stays put unless something it shows has changed. */ +export const BrowseView = memo(BrowseViewImpl); diff --git a/web/src/components/Cover.tsx b/web/src/components/Cover.tsx index da98946..76ca006 100644 --- a/web/src/components/Cover.tsx +++ b/web/src/components/Cover.tsx @@ -9,9 +9,9 @@ import type { CSSProperties } from "react"; -import { coverUrl } from "../api/client"; +import { THUMB_MAX_SIZE, coverUrl } from "../api/client"; import type { Album } from "../api/types"; -import { aspectOf, coverBackground, isBook } from "../lib/covers"; +import { aspectOf, coverBackground, coverUnderlay, isBook } from "../lib/covers"; interface Props { album: Album; @@ -47,7 +47,11 @@ export function Cover({ album, size, fit = "width", radius, className, style, la flex: "none", borderRadius: radius ?? defaultRadius, overflow: "hidden", - background: coverBackground(album, Math.max(6, Math.round(size / 13))), + // Real art is opaque: paint the cheap flat underlay, not the stripes/spine. + background: + album.has_cover && !locked + ? coverUnderlay(album) + : coverBackground(album, Math.max(6, Math.round(size / 13))), ...box, ...style, }} @@ -90,9 +94,13 @@ export function Cover({ album, size, fit = "width", radius, className, style, la )} {album.has_cover && ( + ({ + border: "none", + cursor: "pointer", + background, + color, + fontSize: 15, + fontWeight: 800, + padding: "11px 20px", + borderRadius: 999, + }) as const; + +export function PerfPanel({ onClose }: { onClose: () => void }) { + const [draft, setDraft] = useState({ ...PERF }); + + const save = () => { + savePerfSettings(draft); + location.reload(); + }; + const reset = () => { + resetPerfSettings(); + location.reload(); + }; + + return ( +
+
+
+
+ ⚡ Leistung +
+ +
+ +
+ + +
+ +
+ {TOGGLES.map(([key, label, hint]) => ( + + ))} + + {NUMBERS.map(([key, label, hint, min, max, step]) => ( + + ))} + +
+ + +
+ +
+ Wird nur in diesem Browser gespeichert und gilt nur mit ?pi=1. +
+
+
+
+ ); +} diff --git a/web/src/components/tippen/AquariumCreatures.tsx b/web/src/components/tippen/AquariumCreatures.tsx index 83fc09e..4bfcfc6 100644 --- a/web/src/components/tippen/AquariumCreatures.tsx +++ b/web/src/components/tippen/AquariumCreatures.tsx @@ -14,7 +14,7 @@ import { useEffect, useRef } from "react"; -import { LOW_POWER } from "../../lib/lowPower"; +import { PERF } from "../../lib/perfSettings"; import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium"; import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium"; @@ -42,7 +42,7 @@ export function AquariumCreatures({ creatures, opacity }: Props) { // takes the same door: on that device the point is that nothing runs a frame loop, // and the pets are the reward - they should be *there*, they just need not swim. const reducedMotion = - LOW_POWER || window.matchMedia("(prefers-reduced-motion: reduce)").matches; + !PERF.petAnimations || window.matchMedia("(prefers-reduced-motion: reduce)").matches; let frame = 0; let lastTime = performance.now(); diff --git a/web/src/components/tippen/Stage.tsx b/web/src/components/tippen/Stage.tsx index 42330ae..c18973b 100644 --- a/web/src/components/tippen/Stage.tsx +++ b/web/src/components/tippen/Stage.tsx @@ -9,7 +9,7 @@ import type { ReactNode } from "react"; import type { CreatureId } from "../../lib/tippen/aquarium"; -import { LOW_POWER } from "../../lib/lowPower"; +import { PERF } from "../../lib/perfSettings"; import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme"; import { AquariumCreatures } from "./AquariumCreatures"; import { Bubbles } from "./Bubbles"; @@ -27,7 +27,7 @@ export function Stage({ children, creatures, dimmed }: Props) {
{SHOW_AQUARIUM_CREATURES && } {SHOW_BUBBLES && } diff --git a/web/src/hooks/useCoverWarmup.ts b/web/src/hooks/useCoverWarmup.ts new file mode 100644 index 0000000..b462dc9 --- /dev/null +++ b/web/src/hooks/useCoverWarmup.ts @@ -0,0 +1,55 @@ +/** Pull every card thumbnail into the browser's caches once, while the kiosk is idle. + * + * Cover URLs carry a version and are served `immutable`, so once a thumbnail has been + * fetched the browser never asks for it again - but the *first* search over the whole + * library would otherwise fetch, and decode, hundreds of them while the user waits. This + * does that work up front, a few at a time in idle periods. The `Image`s are held so + * their decoded bitmaps stay resident: RAM is not the constraint on the machine this is + * for (`?pi=1`, see lib/lowPower.ts), the main thread during a keystroke is. + */ + +import { useEffect } from "react"; + +import { coverUrl } from "../api/client"; +import type { Album } from "../api/types"; + +const BATCH = 6; + +const idle = (run: () => void): void => { + if (typeof requestIdleCallback === "function") requestIdleCallback(run, { timeout: 2000 }); + else setTimeout(run, 50); +}; + +/** Module-level on purpose: dropping these would let the browser discard what they hold. */ +const held = new Map(); + +export function useCoverWarmup(albums: Album[], enabled: boolean): void { + useEffect(() => { + if (!enabled) return; + let cancelled = false; + const pending = albums.filter((a) => a.has_cover && !a.locked); + let next = 0; + + const step = () => { + if (cancelled || next >= pending.length) return; + const batch = pending.slice(next, next + BATCH); + next += BATCH; + void Promise.all( + batch.map((album) => { + const url = coverUrl(album.id, { size: "thumb", version: album.cover_v }); + if (held.has(url)) return undefined; + const img = new Image(); + img.decoding = "async"; + img.src = url; + held.set(url, img); + return img.decode().catch(() => undefined); + }), + ).then(() => idle(step)); + }; + idle(step); + + return () => { + cancelled = true; + }; + }, [albums, enabled]); +} diff --git a/web/src/hooks/useEvent.ts b/web/src/hooks/useEvent.ts new file mode 100644 index 0000000..df57409 --- /dev/null +++ b/web/src/hooks/useEvent.ts @@ -0,0 +1,17 @@ +/** A callback with a stable identity that always calls the latest closure. + * + * For handlers passed to a `memo`ised child: `App` re-renders twice a second while a + * track plays (websocket position frames), and a fresh inline handler each time would + * defeat `memo` and re-render the whole card grid with it. */ + +import { useCallback, useLayoutEffect, useRef } from "react"; + +export function useEvent( + handler: (...args: Args) => Result, +): (...args: Args) => Result { + const latest = useRef(handler); + useLayoutEffect(() => { + latest.current = handler; + }); + return useCallback((...args: Args) => latest.current(...args), []); +} diff --git a/web/src/hooks/useIncrementalCount.ts b/web/src/hooks/useIncrementalCount.ts new file mode 100644 index 0000000..20e7263 --- /dev/null +++ b/web/src/hooks/useIncrementalCount.ts @@ -0,0 +1,68 @@ +/** How many items of a long list to render, growing as the user gets near the end. + +Committing every card of a 340-album search result inside one keystroke is what made typing +feel stuck, and it is work for cards nobody has scrolled to. So a list renders its first +`initial` items and asks for `step` more whenever a sentinel placed after the list comes +within a screen of the scroll box's edge, or the keyboard selection reaches the last one +rendered. A new `resetKey` (another query, group or category) starts over at `initial`, so +a short search never inherits the length of the previous long one. + +Growth runs in a transition: the next keystroke interrupts it rather than waiting for it. +*/ + +import { startTransition, useCallback, useEffect, useState } from "react"; +import type { RefObject } from "react"; + +export function nextCount(count: number, step: number, total: number): number { + return Math.min(total, count + step); +} + +export function useIncrementalCount( + total: number, + initial: number, + step: number, + resetKey: string, + root: RefObject, + selected: number, +): { visible: number; sentinelRef: (node: HTMLElement | null) => void } { + const [state, setState] = useState({ key: resetKey, count: initial }); + const [sentinel, setSentinel] = useState(null); + + // Adjusting state during render is React's documented way to reset on a changed input; + // an effect would paint one frame of the old, long list first. + let count = state.count; + if (state.key !== resetKey) { + count = initial; + setState({ key: resetKey, count }); + } else if (count < initial) { + // The grid measured its real column count after first paint. + count = initial; + } + + const grow = useCallback(() => { + startTransition(() => + setState((previous) => ({ ...previous, count: nextCount(Math.max(previous.count, initial), step, total) })), + ); + }, [initial, step, total]); + + // Recreated on every growth: an observer only reports *changes*, so one that stays + // attached to a sentinel that is still on screen would never ask a second time. + useEffect(() => { + if (!sentinel || count >= total) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) grow(); + }, + { root: root.current, rootMargin: "100% 0px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [sentinel, count, total, grow, root]); + + // Arrowing down past the fold: the selected item must exist to be scrolled to. + useEffect(() => { + if (selected >= count - 1 && count < total) grow(); + }, [selected, count, total, grow]); + + return { visible: Math.min(count, total), sentinelRef: setSentinel }; +} diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts index c97e14e..7aeaa0c 100644 --- a/web/src/lib/__tests__/keyboard.test.ts +++ b/web/src/lib/__tests__/keyboard.test.ts @@ -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 { @@ -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 }, + }); + }); }); diff --git a/web/src/lib/__tests__/perfSettings.test.ts b/web/src/lib/__tests__/perfSettings.test.ts new file mode 100644 index 0000000..7a102d8 --- /dev/null +++ b/web/src/lib/__tests__/perfSettings.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const store = new Map(); + +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); + }); +}); diff --git a/web/src/lib/__tests__/search.test.ts b/web/src/lib/__tests__/search.test.ts index 9198352..464ebd0 100644 --- a/web/src/lib/__tests__/search.test.ts +++ b/web/src/lib/__tests__/search.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/covers.ts b/web/src/lib/covers.ts index 3873b6e..d8f134b 100644 --- a/web/src/lib/covers.ts +++ b/web/src/lib/covers.ts @@ -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)` diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts index 9ddd969..508922d 100644 --- a/web/src/lib/keyboard.ts +++ b/web/src/lib/keyboard.ts @@ -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 }]; diff --git a/web/src/lib/lowPower.ts b/web/src/lib/lowPower.ts index 9402ba5..7796a85 100644 --- a/web/src/lib/lowPower.ts +++ b/web/src/lib/lowPower.ts @@ -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; diff --git a/web/src/lib/perfSettings.ts b/web/src/lib/perfSettings.ts new file mode 100644 index 0000000..6d107f5 --- /dev/null +++ b/web/src/lib/perfSettings.ts @@ -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 { + if (typeof raw !== "object" || raw === null) return {}; + const out: Record = {}; + for (const [key, def] of Object.entries(PI)) { + const value = (raw as Record)[key]; + if (typeof value === typeof def && (typeof value !== "number" || Number.isFinite(value))) { + out[key] = value as boolean | number; + } + } + return out as Partial; +} + +export function loadStoredPerf(): Partial { + 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(); diff --git a/web/src/lib/search.ts b/web/src/lib/search.ts index 0584a80..4458615 100644 --- a/web/src/lib/search.ts +++ b/web/src/lib/search.ts @@ -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, diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts index b9e059c..8443864 100644 --- a/web/src/lib/theme.ts +++ b/web/src/lib/theme.ts @@ -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 -- diff --git a/web/src/lib/tippen/theme.ts b/web/src/lib/tippen/theme.ts index 00c89b7..d511f1e 100644 --- a/web/src/lib/tippen/theme.ts +++ b/web/src/lib/tippen/theme.ts @@ -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 diff --git a/web/src/main.tsx b/web/src/main.tsx index d075851..8194c57 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { PERF } from "./lib/perfSettings"; import "./styles/app.css"; import "./styles/room.css"; import "./styles/tippen.css"; @@ -15,8 +16,20 @@ createRoot(document.getElementById("root")!).render( // Registered after load so it never competes with the first paint. It is what makes // iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js // for what it does and deliberately does not cache. +// +// Not under `?pi=1`: that is the kiosk, which *is* the server. There is nothing to +// install, and the worker only adds a fetch handler (and a `cache.put` of every response) +// in front of requests that are already on localhost. Any worker an earlier visit left +// behind is removed so the kiosk stops paying for it. if ("serviceWorker" in navigator && import.meta.env.PROD) { window.addEventListener("load", () => { + if (!PERF.serviceWorker) { + void navigator.serviceWorker + .getRegistrations() + .then((registrations) => Promise.all(registrations.map((r) => r.unregister()))) + .catch(() => undefined); + return; + } void navigator.serviceWorker.register("/sw.js").catch(() => { // A plain http:// origin that is not localhost cannot register one. The app // works exactly the same, it just cannot be installed. diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 55ff3fb..95d2723 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -104,12 +104,11 @@ button { height: 100vh; height: 100dvh; overflow: hidden; - background: linear-gradient( - 180deg, - oklch(55% 0.07 210) 0%, - oklch(38% 0.06 210) 45%, - var(--sea-deep) 100% - ); + /* Gradient stops are plain sRGB hex, not oklch(): a gradient with an oklch stop + interpolates in Oklab, which Gecko expands stop by stop on the CPU every time the + display list is rebuilt - about 15% of a Pi-profile's busy time. Values are the + oklch(55% 0.07 210) / (38% 0.06 210) / (20% 0.045 210) they replace. */ + background: linear-gradient(180deg, #397d88 0%, #0e4b54 45%, #001b21 100%); } /* Still used by the room-control page's own `` field (`RoomView.tsx`) - the @@ -270,7 +269,7 @@ button { flex: none; border-radius: 999px; font-size: 17px; - background: linear-gradient(160deg, oklch(97% 0.01 210 / 0.38), oklch(97% 0.01 210 / 0.12)); + background: linear-gradient(160deg, #eef7f961, #eef7f91f) /* oklch(97% 0.01 210 / .38 -> .12) */; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid oklch(97% 0.01 210 / 0.4); @@ -282,7 +281,7 @@ button { /* A frosted panel wrapped around a shelf/list of rows so they read as a distinct surface floating over the stage gradient, rather than flat text on a gradient. */ .glass-panel { - background: linear-gradient(160deg, oklch(97% 0.01 210 / 0.14), oklch(97% 0.01 210 / 0.05)); + background: linear-gradient(160deg, #eef7f924, #eef7f90d) /* oklch(97% 0.01 210 / .14 -> .05) */; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid oklch(97% 0.01 210 / 0.16); @@ -344,3 +343,89 @@ input[type="number"], input[type="range"] { font: inherit; } + +/* The search field. Big on purpose: typing is how the whole app is driven, so where the + letters land has to be unmissable. */ +.searchbox-row { + flex: none; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + margin: 6px 32px 10px; +} + +.searchbox { + display: flex; + align-items: center; + gap: 16px; + width: min(680px, 100%); + min-height: 68px; + box-sizing: border-box; + padding: 10px 26px 10px 14px; + border-radius: 999px; + background: var(--paper); + color: var(--ink); + border: 3px solid var(--accent); + box-shadow: 0 6px 22px var(--shadow); + font-size: 30px; + font-weight: 800; +} + +.searchbox-mode { + flex: none; + font-size: 16px; + padding: 8px 14px; + border-radius: 999px; + background: var(--ink); + color: #fff; + border: none; + font: inherit; + font-size: 16px; + cursor: pointer; +} + +.searchbox-mode:hover { + background: var(--accent); +} + +.searchbox-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.searchbox-placeholder { + color: oklch(30% 0.04 210 / .45); +} + +.searchbox-caret { + display: inline-block; + width: 3px; + height: 1em; + margin-left: 3px; + vertical-align: -0.12em; + background: var(--accent); + animation: searchbox-blink 1.1s steps(1) infinite; +} + +/* Empty: the caret sits where the first letter will land, ahead of the placeholder. */ +.searchbox[data-empty] .searchbox-caret { + margin: 0 3px 0 0; +} + +.searchbox-note { + color: oklch(90% 0.02 210 / .85); + font-size: 15px; + font-weight: 700; +} + +@keyframes searchbox-blink { + 50% { opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .searchbox-caret { animation: none; } +}