Files
musicmouse/web/src/components/BrowseView.tsx
Martin Bauer cc3db44c4e Add a ?pi=1 profile, and stop re-deriving search keys per keystroke
Two separate costs, both measured on musicdolphin (Pi 4, 1920x1080 kiosk), where the
app was burning ~70% of a core with nothing happening on screen.

Per-frame work. The ambient canvas repaints a full-screen gradient plus a particle
field every frame, and `usePlaybackClock` pushes a React setState per animation frame
into both PlayView and PlayerBar for the whole length of a track. `?pi=1` (lib/
lowPower.ts) makes those cheaper rather than switching them off: the canvas paints a
quarter of the pixels at 30fps with a bubble cap, and the clock renders ten times a
second - a progress bar advances one pixel every few hundred ms and its label has
one-second resolution, so nothing on screen can tell. Only the effects with no cheap
version actually go: the backdrop-filter glass blur and the decorative CSS loops.
Also drops a redundant full-canvas clearRect that the opaque gradient always covered.

Search. normalize() runs a Unicode NFD decomposition, and albumMatches/songMatches
called it on every album title and every track title on every keystroke - 4969 of them
for a track search, whose answer cannot change until the library does. buildSearchIndex
does it once per library payload; a keystroke is now String.includes over strings that
already exist. On the real library that is 33ms -> 3.4ms for an eight-letter track
query on a laptop, and this runs on a Pi. The same index partitions albums by shelf and
pre-sorts each shelf's categories, which App and BrowseView were deriving separately
from the same data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:15:23 +02:00

807 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** The browse screen. At the root (no group chosen) this is three horizontally
* scrolling shelves - music, audiobooks, podcasts - each showing its own category
* tiles. Once a group is open it behaves like before: category tiles, or the
* albums-within-a-category grid, or the track list, with selection indices running
* flat across songs, then categories, then albums so one pair of arrow keys walks
* the whole page. */
import { useEffect, useMemo, useRef } from "react";
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
import type { Album } from "../api/types";
import {
albumLine,
aspectOfKind,
cardBackground,
cardShadow,
isBook,
unitLabel,
} 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 {
ANIMATE_VIEW_TRANSITIONS,
glassTint,
GROUP_HUE,
GROUP_ICON,
GROUP_LABEL,
ROW_SPACING,
rowTint,
SHOW_ROW_ICONS,
SHOW_ROW_TITLES,
TAB_RAIL_CLEARANCE,
} from "../lib/theme";
import { Cover } from "./Cover";
interface Props {
results: Results;
group: Group | null;
index: SearchIndex;
mode: "albums" | "tracks";
search: string;
category: string | null;
selIndex: number;
/** Which of the three root shelves is focused - only meaningful at the bare root,
* where `selIndex` means "which tile in that row" instead of a flat position. */
shelfRow: number;
currentAlbumId: string | null;
gridRef: (element: HTMLElement | null) => void;
onEnterGroup: (group: Group, category: string | null) => void;
onCategory: (key: string | null) => void;
/** Cover click: open the track list (or, for a podcast episode, play it - there's
* nothing to pick between). */
onOpenAlbum: (album: Album, navIndex: number) => void;
/** Title click: start the album from the top right away. */
onPlayAlbum: (album: Album, navIndex: number) => void;
onPlaySong: (hit: SongHit) => void;
}
const GROUPS: Group[] = ["music", "audiobooks", "podcasts"];
/** Heading over the category tiles inside a group. */
const GROUP_CATEGORY_LABEL: Record<Group, string> = {
music: "Künstler",
audiobooks: "Figuren",
podcasts: "Sendungen",
};
/** Heading over the album grid while searching inside a group. */
const GROUP_SECTION_LABEL: Record<Group, string> = {
music: "Alben",
audiobooks: "Hörbücher",
podcasts: "Episoden",
};
export function BrowseView({
results,
group,
index,
mode,
search,
category,
selIndex,
shelfRow,
currentAlbumId,
gridRef,
onEnterGroup,
onCategory,
onOpenAlbum,
onPlayAlbum,
onPlaySong,
}: Props) {
const scroller = useRef<HTMLDivElement | null>(null);
const { songs, categories, albums: albumResults, total } = results;
const isRootShelf = group === null && mode !== "tracks" && !search;
// 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`
// is which of the three rows is focused, `selIndex` which tile within that row,
// mirroring keyboard.ts's `moveRootShelf`. `selected` here only drives the outer
// scroller's keep-the-focused-row-on-screen effect below, so at the root it only
// 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 shelves = useMemo(
() =>
GROUPS.map((shelfGroup) => ({
group: shelfGroup,
categories: categoryMatches({
index,
search: "",
mode: "albums",
category: null,
group: shelfGroup,
}),
})),
[index],
);
// Keep the selection on screen as the arrow keys walk past the fold.
useEffect(() => {
const box = scroller.current;
const element = box?.querySelector<HTMLElement>(`[data-nav-index="${selected}"]`);
if (!box || !element) return;
const top = element.offsetTop - box.offsetTop;
const bottom = top + element.offsetHeight;
const pad = 24;
if (top - pad < box.scrollTop) box.scrollTop = Math.max(0, top - pad);
else if (bottom + pad > box.scrollTop + box.clientHeight) {
box.scrollTop = bottom + pad - box.clientHeight;
}
}, [selected]);
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>
);
}
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";
return (
<>
{group !== null && (
<div
style={{
textAlign: "center",
padding: "4px 32px 6px",
flex: "none",
fontSize: 15,
fontWeight: 800,
color: "var(--paper)",
}}
>
{GROUP_LABEL[group]}
</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>
)}
<div
ref={scroller}
key={`${group}:${category}:${mode}`}
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` }}
>
{songs.length > 0 && (
<div style={{ marginBottom: 30 }}>
<SectionTitle>
Titel <Muted>{songs.length} Treffer</Muted>
</SectionTitle>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
maxWidth: 760,
margin: "0 auto",
}}
>
{songs.map((hit, index) => (
<button
key={`${hit.album.id}-${hit.index}`}
data-nav-index={index}
onClick={() => onPlaySong(hit)}
className="list-row"
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "8px 16px",
borderRadius: 12,
cursor: "pointer",
textAlign: "left",
font: "inherit",
...rowTint(GROUP_HUE[groupOf(hit.album)], currentAlbumId === hit.album.id),
outline:
selected === index ? "4px solid var(--paper)" : undefined,
outlineOffset: 2,
}}
>
<div style={{ height: 40, flex: "none", display: "flex" }}>
<Cover
album={hit.album}
size={40}
fit="height"
radius={isBook(hit.album) ? "5px 10px 10px 5px" : "10px"}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, color: "var(--paper)" }}>
{hit.title}
</div>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .65)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{albumLine(hit.album)}
</div>
</div>
<div
style={{
font: "700 13px ui-monospace, Menlo, monospace",
color: "oklch(88% 0.02 210 / .6)",
}}
>
{clock(hit.duration)}
</div>
</button>
))}
</div>
</div>
)}
{categories.length > 0 && group !== null && (
<div>
<SectionTitle centered>{GROUP_CATEGORY_LABEL[group]}</SectionTitle>
<div className="grid" ref={gridRef}>
{categories.map((entry, index) => {
const navIndex = songs.length + index;
return (
<CategoryTile
key={entry.key}
entry={entry}
navIndex={navIndex}
selected={selected === navIndex}
onClick={() => onCategory(entry.key)}
/>
);
})}
</div>
</div>
)}
{albumResults.length > 0 && (
<div>
{category !== null && !search && (
<div
style={{
textAlign: "center",
marginBottom: 14,
fontSize: 22,
fontWeight: 900,
color: "var(--paper)",
}}
>
{category}
</div>
)}
{search.length > 0 && group !== null && (
<SectionTitle centered>{GROUP_SECTION_LABEL[group]}</SectionTitle>
)}
<div className="grid" ref={gridRef}>
{albumResults.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/
// series it's already grouped under, so dropping it gives the title
// more room. A podcast's artist is the show name, which does carry
// information, so it stays.
const showArtist = groupOf(album) !== "audiobooks";
// Fixed regardless of how many lines the title actually needs, so
// every card - and thus every row of covers - is the same height.
const titleLines = showArtist ? 2 : 3;
const hint = album.locked ? (album.tracks[0]?.unlock_hint ?? null) : null;
return (
<div
key={album.id}
className="card"
data-nav-index={navIndex}
data-selected={selected === navIndex}
data-current={album.id === currentAlbumId}
data-locked={album.locked}
style={{
background: cardBackground(album),
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
boxShadow: cardShadow(album),
}}
>
<button
onClick={() => !album.locked && onOpenAlbum(album, navIndex)}
disabled={album.locked}
aria-label={
album.locked
? `${album.title}: noch nicht freigeschaltet`
: podcast
? `${album.title} abspielen`
: `${album.title}: Titel wählen`
}
style={{
display: "flex",
width: "100%",
border: "none",
padding: 0,
cursor: album.locked ? "default" : "pointer",
}}
>
<Cover album={album} size={180} radius="0" label locked={album.locked} />
</button>
<button
onClick={() => !album.locked && onPlayAlbum(album, navIndex)}
disabled={album.locked}
aria-label={album.locked ? `${album.title}: noch nicht freigeschaltet` : `${album.title} abspielen`}
style={{
display: "block",
width: "100%",
border: "none",
cursor: album.locked ? "default" : "pointer",
textAlign: "left",
font: "inherit",
background: "none",
padding: "10px 12px 14px",
paddingRight: isBook(album) ? 22 : 12,
}}
>
{album.locked ? (
<>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
}}
>
Geheimnis
</div>
{hint && (
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "oklch(40% 0.03 210)",
marginTop: 4,
lineHeight: 1.3,
}}
>
Freigeschaltet nach {hint.lesson_title}" (Welt {hint.world_number})
</div>
)}
</>
) : (
<>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
height: `${titleLines * 1.2}em`,
display: "-webkit-box",
WebkitLineClamp: titleLines,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{album.title}
</div>
{showArtist && (
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(30% 0.03 210)",
marginTop: 2,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{album.artist}
</div>
)}
<div
style={{
fontSize: 12,
fontWeight: 800,
color: "oklch(40% 0.17 340)",
marginTop: 6,
}}
>
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"}
</div>
</>
)}
</button>
</div>
);
})}
</div>
</div>
)}
{total === 0 && (
<div
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 }}
/>
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}>
Nichts gefunden — probier andere Buchstaben!
</div>
</div>
)}
</div>
</>
);
}
/** A `.shelf-row` that turns a vertical wheel gesture into horizontal scroll, and lets
* a mouse click-and-drag scroll it too - the point of hovering/dragging a shelf with a
* mouse rather than a touchscreen, which already scrolls it natively by touch.
*
* The wheel listener has to be a real (non-passive) one: React's own `onWheel` is
* passive by default, so calling `preventDefault` there would only log a warning and
* still scroll the page. Drag-to-scroll is plain mouse events on `window` rather than
* pointer capture, so a plain click still reaches the tile underneath - capturing the
* pointer on the row would retarget even a non-dragging click's events to the row. */
function ShelfRow({
children,
focusedIndex,
}: {
children: ReactNode;
/** Which tile in this row the keyboard is on, or `null` while some other row has
* focus - keeps a Ctrl+h/l selection that's scrolled off the edge on screen, the
* horizontal equivalent of the outer scroller's own keep-in-view effect above. */
focusedIndex: number | null;
}) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = ref.current;
if (!element || focusedIndex === null) return;
const tile = element.querySelector<HTMLElement>(`[data-nav-index="${focusedIndex}"]`);
if (!tile) return;
const left = tile.offsetLeft;
const right = left + tile.offsetWidth;
const pad = 24;
if (left - pad < element.scrollLeft) element.scrollLeft = Math.max(0, left - pad);
else if (right + pad > element.scrollLeft + element.clientWidth) {
element.scrollLeft = right + pad - element.clientWidth;
}
}, [focusedIndex]);
useEffect(() => {
const element = ref.current;
if (!element) return;
const onWheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
element.scrollLeft += event.deltaY;
event.preventDefault();
};
element.addEventListener("wheel", onWheel, { passive: false });
return () => element.removeEventListener("wheel", onWheel);
}, []);
const onMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
const element = ref.current;
if (event.button !== 0 || !element) return;
event.preventDefault(); // no text-selection/ghost-drag while panning
const startX = event.clientX;
const startScroll = element.scrollLeft;
let dragged = false;
const onMove = (moveEvent: MouseEvent) => {
const dx = moveEvent.clientX - startX;
if (Math.abs(dx) > 4) {
dragged = true;
element.classList.add("dragging");
}
element.scrollLeft = startScroll - dx;
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
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("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
return (
<div className="shelf-row" ref={ref} onMouseDown={onMouseDown}>
{children}
</div>
);
}
/** One category's 2x2 cover preview + name + count. Used both by the root shelves
* (pointer-only) and by the in-group category grid (keyboard-navigable, hence the
* optional `navIndex`/`selected`). */
function CategoryTile({
entry,
navIndex,
selected,
onClick,
}: {
entry: Category;
navIndex?: number;
selected?: boolean;
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
}) {
const books = entry.albums.filter(isBook).length;
const allBooks = books === entry.albums.length;
const mostlyBooks = books * 2 > entry.albums.length;
const shown = entry.albums.slice(0, 4);
return (
<button
className="card"
data-nav-index={navIndex}
data-selected={selected}
onClick={onClick}
style={{
background: allBooks
? `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)",
}}
>
<div
style={{
display: "grid",
// A category of one gets one full-bleed cover rather than a 2x2 grid with
// three empty holes in it. Always a full 2x2 for more than one, so every
// cell has the same shape as the tile and the covers fill it exactly. Two
// albums in a single row would each be twice as wide as their cell and
// spill out of it.
gridTemplateColumns: shown.length === 1 ? "1fr" : "1fr 1fr",
gridTemplateRows: shown.length === 1 ? "1fr" : "1fr 1fr",
gap: 4,
padding: 8,
// The tile takes the shape of what it holds, so a shelf of audiobooks is
// visibly taller than a shelf of albums.
aspectRatio: aspectOfKind(mostlyBooks ? "book" : "music"),
}}
>
{shown.map((album) => (
<div
key={album.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<Cover
album={album}
size={shown.length === 1 ? 160 : 70}
fit="height"
radius="4px"
label={shown.length === 1}
locked={album.locked}
/>
</div>
))}
</div>
<div style={{ padding: "4px 12px 14px" }}>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
}}
>
{entry.key}
</div>
<div
style={{
fontSize: 12,
fontWeight: 800,
color: "oklch(40% 0.17 340)",
marginTop: 5,
}}
>
{entry.albums.length}{" "}
{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;
}) {
return (
<div
style={{
fontSize: 19,
fontWeight: 900,
color: "var(--paper)",
marginBottom: 12,
textAlign: centered ? "center" : "left",
display: centered ? "block" : "flex",
alignItems: "baseline",
justifyContent: "center",
gap: 10,
}}
>
{children}
</div>
);
}
function Muted({ children }: { children: ReactNode }) {
return (
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
{children}
</span>
);
}