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>
807 lines
28 KiB
TypeScript
807 lines
28 KiB
TypeScript
/** 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>
|
||
);
|
||
}
|