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);
|
||||
|
||||
Reference in New Issue
Block a user