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

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