Web frontend: perf panel, cover warmup, incremental browse rendering

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 11:42:33 +02:00
parent 9fd106b757
commit 2db4368fc9
24 changed files with 1178 additions and 255 deletions

View File

@@ -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() {
</div>
)}
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
<Suspense fallback={null}>
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
{ui.page === "typing" && (
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
)}
{ui.page === "typing" && (
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
)}
</Suspense>
{openAlbum && state && (
<AlbumModal
@@ -559,6 +585,30 @@ export function App() {
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
{PERF_ENABLED && !perfOpen && (
<button
onClick={() => setPerfOpen(true)}
aria-label="Leistungseinstellungen"
style={{
position: "absolute",
left: 8,
bottom: 8,
zIndex: 5,
width: 32,
height: 32,
border: "none",
borderRadius: 999,
cursor: "pointer",
background: "oklch(15% 0.03 210 / .35)",
color: "#fff",
opacity: 0.5,
}}
>
</button>
)}
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
{(!state || library.loading) && <Splash error={library.error} />}
{debugDynamicUI && debugSnapshot && (

View File

@@ -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");
});
});

View File

@@ -122,4 +122,20 @@ export const api = {
request<TippenRunResult>("/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;

View File

@@ -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. */

View File

@@ -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<Group, string> = {
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<HTMLDivElement | null>(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 (
<div
ref={scroller}
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
// Right padding reserves room for the tab rail's own glass bar (see
// TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it.
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px` }}
>
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
<div
key={shelfGroup}
className="glass-panel"
role="button"
tabIndex={0}
data-nav-index={index}
onClick={() => 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) && (
<div
style={{
display: "flex",
alignItems: "center",
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
marginBottom: 12,
fontSize: 20,
fontWeight: 900,
color: "var(--paper)",
}}
>
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}></span>}
</div>
)}
{shelfCategories.length === 0 ? (
<div
style={{
textAlign: "center",
fontSize: 14,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .6)",
}}
>
Noch nichts hier
</div>
) : (
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
{shelfCategories.map((entry, tileIndex) => (
<CategoryTile
key={entry.key}
entry={entry}
navIndex={tileIndex}
selected={focusedRow === index && focusedCol === tileIndex}
onClick={(event) => {
// 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);
}}
/>
))}
</ShelfRow>
)}
</div>
))}
</div>
<>
<SearchBox
typed={typed}
mode={mode}
open={searchOpen}
onToggleMode={onToggleMode}
note=""
/>
<div
ref={scroller}
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
// Right padding reserves room for the tab rail's own glass bar (see
// TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it.
style={{
flex: 1,
overflow: "auto",
minHeight: 0,
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
}}
>
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
<div
key={shelfGroup}
className="glass-panel"
role="button"
tabIndex={0}
data-nav-index={index}
onClick={() => 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) && (
<div
style={{
display: "flex",
alignItems: "center",
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
marginBottom: 12,
fontSize: 20,
fontWeight: 900,
color: "var(--paper)",
}}
>
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}></span>}
</div>
)}
{shelfCategories.length === 0 ? (
<div
style={{
textAlign: "center",
fontSize: 14,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .6)",
}}
>
Noch nichts hier
</div>
) : (
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
{shelfCategories.map((entry, tileIndex) => (
<CategoryTile
key={entry.key}
entry={entry}
navIndex={tileIndex}
selected={focusedRow === index && focusedCol === tileIndex}
onClick={(event) => {
// 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);
}}
/>
))}
</ShelfRow>
)}
</div>
))}
</div>
</>
);
}
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({
</div>
)}
{showSearchBar && (
<div
style={{
margin: "2px 32px 6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
flex: "none",
}}
>
<div
style={{
background: "var(--paper)",
color: "var(--ink)",
fontWeight: 800,
fontSize: 20,
padding: "10px 20px",
borderRadius: 999,
boxShadow: "0 4px 14px var(--shadow)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span
style={{
fontSize: 14,
fontWeight: 800,
background: "var(--ink)",
color: "#fff",
padding: "4px 10px",
borderRadius: 999,
}}
>
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"}
</span>
<span>{search ? `"${search}"` : "tippe …"}</span>
</div>
<div style={{ color: "oklch(90% 0.02 210 / .85)", fontWeight: 700, fontSize: 14 }}>
{countLabel}
</div>
</div>
)}
<SearchBox
typed={typed}
mode={mode}
open={searchOpen}
onToggleMode={onToggleMode}
note={countLabel}
/>
<div
ref={scroller}
@@ -304,7 +327,12 @@ export function BrowseView({
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
// Right padding reserves room for the tab rail's own glass bar (see
// TAB_RAIL_CLEARANCE) so a full-width row/grid never renders under it.
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px` }}
style={{
flex: 1,
overflow: "auto",
minHeight: 0,
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
}}
>
{songs.length > 0 && (
<div style={{ marginBottom: 30 }}>
@@ -320,7 +348,7 @@ export function BrowseView({
margin: "0 auto",
}}
>
{songs.map((hit, index) => (
{songs.slice(0, songWindow.visible).map((hit, index) => (
<button
key={`${hit.album.id}-${hit.index}`}
data-nav-index={index}
@@ -336,8 +364,7 @@ export function BrowseView({
textAlign: "left",
font: "inherit",
...rowTint(GROUP_HUE[groupOf(hit.album)], currentAlbumId === hit.album.id),
outline:
selected === index ? "4px solid var(--paper)" : undefined,
outline: selected === index ? "4px solid var(--paper)" : undefined,
outlineOffset: 2,
}}
>
@@ -350,7 +377,13 @@ export function BrowseView({
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, color: "var(--paper)" }}>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "var(--paper)",
}}
>
{hit.title}
</div>
<div
@@ -377,6 +410,9 @@ export function BrowseView({
</button>
))}
</div>
{songWindow.visible < songs.length && (
<div ref={songWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
)}
</div>
)}
@@ -384,7 +420,7 @@ export function BrowseView({
<div>
<SectionTitle centered>{GROUP_CATEGORY_LABEL[group]}</SectionTitle>
<div className="grid" ref={gridRef}>
{categories.map((entry, index) => {
{categories.slice(0, categoryWindow.visible).map((entry, index) => {
const navIndex = songs.length + index;
return (
<CategoryTile
@@ -397,6 +433,9 @@ export function BrowseView({
);
})}
</div>
{categoryWindow.visible < categories.length && (
<div ref={categoryWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
)}
</div>
)}
@@ -419,7 +458,7 @@ export function BrowseView({
<SectionTitle centered>{GROUP_SECTION_LABEL[group]}</SectionTitle>
)}
<div className="grid" ref={gridRef}>
{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({
<button
onClick={() => !album.locked && onPlayAlbum(album, navIndex)}
disabled={album.locked}
aria-label={album.locked ? `${album.title}: noch nicht freigeschaltet` : `${album.title} abspielen`}
aria-label={
album.locked
? `${album.title}: noch nicht freigeschaltet`
: `${album.title} abspielen`
}
style={{
display: "block",
width: "100%",
@@ -547,7 +590,9 @@ export function BrowseView({
marginTop: 6,
}}
>
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
{podcast
? clock(album.duration)
: unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"}
</div>
</>
@@ -557,20 +602,34 @@ export function BrowseView({
);
})}
</div>
{albumWindow.visible < albumResults.length && (
<div ref={albumWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
)}
</div>
)}
{total === 0 && (
<div
style={{ textAlign: "center", marginTop: 60, color: "oklch(92% 0.02 210 / .8)" }}
style={{
textAlign: "center",
marginTop: 60,
color: "oklch(92% 0.02 210 / .8)",
}}
>
<img
src="/dolphin-mascot.png"
alt=""
style={{ width: 120, height: 120, objectFit: "contain", opacity: 0.9 }}
style={{
width: 120,
height: 120,
objectFit: "contain",
opacity: 0.9,
}}
/>
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}>
Nichts gefunden — probier andere Buchstaben!
{search
? "Nichts gefunden probier andere Buchstaben!"
: "Tippe mindestens zwei Buchstaben "}
</div>
</div>
)}
@@ -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",
}}
>
<div
@@ -758,26 +818,24 @@ function CategoryTile({
}}
>
{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"}
</div>
</div>
</button>
);
}
function SectionTitle({
children,
centered,
}: {
children: ReactNode;
centered?: boolean;
}) {
function SectionTitle({ children, centered }: { children: ReactNode; centered?: boolean }) {
return (
<div
style={{
@@ -799,8 +857,63 @@ function SectionTitle({
function Muted({ children }: { children: ReactNode }) {
return (
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
<span
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .7)",
}}
>
{children}
</span>
);
}
/** 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 (
<div className="searchbox-row">
<div className="searchbox" data-empty={typed ? undefined : true}>
{/* 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. */}
<button
type="button"
className="searchbox-mode"
onMouseDown={(event) => event.preventDefault()}
onClick={onToggleMode}
title="Suchmodus wechseln ( / )"
>
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"}
</button>
<span className="searchbox-text">
{!typed && <span className="searchbox-caret" aria-hidden />}
{typed || <span className="searchbox-placeholder">Tippe zum Suchen </span>}
{typed && <span className="searchbox-caret" aria-hidden />}
</span>
</div>
{note && <div className="searchbox-note">{note}</div>}
</div>
);
}
/** 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);

View File

@@ -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 && (
<img
src={coverUrl(album.id)}
src={coverUrl(album.id, {
size: size <= THUMB_MAX_SIZE ? "thumb" : "full",
version: album.cover_v,
})}
alt=""
loading="lazy"
decoding="async"
style={{
position: "absolute",
inset: 0,

View File

@@ -0,0 +1,158 @@
/** Per-knob performance settings, shown under `?pi=1`. Stored in this browser's
* localStorage (see lib/perfSettings.ts) and applied by a reload, because the knobs are
* read once at module load. Nothing changes until "Speichern". */
import { useState } from "react";
import {
FULL,
PERF,
PI,
resetPerfSettings,
savePerfSettings,
type PerfSettings,
} from "../lib/perfSettings";
type BoolKey = { [K in keyof PerfSettings]: PerfSettings[K] extends boolean ? K : never }[keyof PerfSettings];
type NumKey = { [K in keyof PerfSettings]: PerfSettings[K] extends number ? K : never }[keyof PerfSettings];
const TOGGLES: [BoolKey, string, string][] = [
["ambience", "Animierter Hintergrund", "Canvas im Abspielbildschirm (größter CPU-Verbraucher)"],
["cardBlur", "Weichzeichner auf Karten", "Backdrop-Blur je Album-Karte und Titelzeile"],
["viewTransitions", "Übergänge", "Ein-/Ausblenden beim Öffnen von Gruppen und Alben"],
["decorativeAnimations", "Deko-Animationen", "Blasen im Zimmer, schwimmender Delfin"],
["cardShadows", "Schatten auf Karten", "Schlagschatten unter den Album-Karten"],
["tileShadows", "Schatten auf Kacheln", "Schlagschatten unter den Kacheln im Raster"],
["petAnimations", "Tippen: Tiere bewegen sich", "Aus: Tiere stehen still"],
["tippenBubbles", "Tippen: Blasen", "Aufsteigende Blasen im Hintergrund"],
["tippenViewTransitions", "Tippen: Übergänge", "Ein-/Ausblenden der Bildschirme"],
["serviceWorker", "Service Worker", "Aus: vorhandener Worker wird entfernt"],
["coverWarmup", "Cover vorladen", "Cover im Hintergrund dekodieren"],
];
const NUMBERS: [NumKey, string, string, number, number, number][] = [
["playbackClockFps", "Fortschrittsbalken: Updates/s", "0 = jeder Frame", 0, 60, 1],
["ambienceScale", "Hintergrund: Auflösung", "Faktor der Canvas-Größe", 0.1, 1, 0.05],
["ambienceMaxPx", "Hintergrund: max. Pixel", "Obergrenze je Achse", 256, 4096, 64],
["ambienceMaxFps", "Hintergrund: max. FPS", "0 = unbegrenzt", 0, 60, 1],
["ambienceMaxBubbles", "Hintergrund: max. Blasen", "0 = unbegrenzt", 0, 500, 5],
];
const pill = (background: string, color: string) =>
({
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<PerfSettings>({ ...PERF });
const save = () => {
savePerfSettings(draft);
location.reload();
};
const reset = () => {
resetPerfSettings();
location.reload();
};
return (
<div className="overlay" style={{ zIndex: 7, background: "oklch(15% 0.03 210 / .75)" }}>
<div className="sheet" style={{ padding: 28, width: 480, maxHeight: "100%", overflow: "auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
Leistung
</div>
<button
onClick={onClose}
aria-label="Schließen"
style={{
...pill("oklch(88% 0.02 210)", "var(--ink)"),
fontSize: 18,
fontWeight: 900,
width: 36,
height: 36,
padding: 0,
}}
>
×
</button>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
<button onClick={() => setDraft({ ...PI })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
Pi-Vorgabe
</button>
<button onClick={() => setDraft({ ...FULL })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
Alles an
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 18 }}>
{TOGGLES.map(([key, label, hint]) => (
<label key={key} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<input
type="checkbox"
checked={draft[key]}
onChange={(event) => setDraft({ ...draft, [key]: event.target.checked })}
style={{ width: 22, height: 22 }}
/>
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
{hint}
</span>
</span>
</label>
))}
{NUMBERS.map(([key, label, hint, min, max, step]) => (
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
{hint}
</span>
<input
type="number"
min={min}
max={max}
step={step}
value={draft[key]}
onChange={(event) => {
const value = event.target.valueAsNumber;
if (Number.isFinite(value)) setDraft({ ...draft, [key]: value });
}}
style={{
padding: "8px 12px",
borderRadius: 10,
border: "2px solid oklch(85% 0.02 210)",
fontSize: 16,
fontWeight: 700,
}}
/>
</label>
))}
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
<button onClick={save} style={pill("var(--accent)", "#fff")}>
Speichern &amp; neu laden
</button>
<button onClick={reset} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
Zurücksetzen
</button>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)", lineHeight: 1.5 }}>
Wird nur in diesem Browser gespeichert und gilt nur mit <code>?pi=1</code>.
</div>
</div>
</div>
</div>
);
}

View File

@@ -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();

View File

@@ -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) {
<div
className="tp-stage"
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
data-anim={LOW_POWER ? "off" : "on"}
data-anim={PERF.petAnimations ? "on" : "off"}
>
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
{SHOW_BUBBLES && <Bubbles />}

View File

@@ -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<string, HTMLImageElement>();
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]);
}

17
web/src/hooks/useEvent.ts Normal file
View File

@@ -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<Args extends unknown[], Result>(
handler: (...args: Args) => Result,
): (...args: Args) => Result {
const latest = useRef(handler);
useLayoutEffect(() => {
latest.current = handler;
});
return useCallback((...args: Args) => latest.current(...args), []);
}

View File

@@ -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<HTMLElement | null>,
selected: number,
): { visible: number; sentinelRef: (node: HTMLElement | null) => void } {
const [state, setState] = useState({ key: resetKey, count: initial });
const [sentinel, setSentinel] = useState<HTMLElement | null>(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 };
}

View File

@@ -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 },
});
});
});

View 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);
});
});

View File

@@ -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);
});
});

View File

@@ -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)`

View File

@@ -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 }];

View File

@@ -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
View 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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 `<Bubbles>` 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; }
}