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