Web frontend: perf panel, cover warmup, incremental browse rendering
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
158
web/src/components/PerfPanel.tsx
Normal file
158
web/src/components/PerfPanel.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
Reference in New Issue
Block a user