This commit is contained in:
2026-08-27 23:46:19 +02:00
parent a8ed350aec
commit 8aed3b022b
16 changed files with 602 additions and 186 deletions

View File

@@ -1,8 +1,11 @@
/** The browse screen: filter pills, the search pill, and whichever of the three result
* lists applies. Selection indices run flat across songs, then categories, then albums,
* which is what lets one pair of arrow keys walk the whole page. */
/** 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, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import type { Album } from "../api/types";
import {
@@ -14,48 +17,86 @@ import {
unitLabel,
} from "../lib/covers";
import { clock } from "../lib/format";
import type { Filter, Results, SongHit } from "../lib/search";
import type { Category, Group, Results, SongHit } from "../lib/search";
import { categoryMatches, groupOf } from "../lib/search";
import { Cover } from "./Cover";
interface Props {
results: Results;
filter: Filter;
group: Group | null;
albums: Album[];
mode: "albums" | "tracks";
search: string;
category: string | null;
selIndex: number;
currentAlbumId: string | null;
gridRef: (element: HTMLElement | null) => void;
onFilter: (filter: Filter) => void;
onEnterGroup: (group: Group, category: string | null) => void;
onBackToRoot: () => void;
onCategory: (key: string | null) => void;
onOpenAlbum: (album: Album, navIndex: number) => void;
onPlaySong: (hit: SongHit) => void;
}
const FILTERS: Array<[Filter, string]> = [
["all", "Alles"],
["music", "🎵 Musik"],
["book", "📖 Hörbücher"],
];
const GROUPS: Group[] = ["music", "audiobooks", "podcasts"];
const GROUP_TITLE: Record<Group, string> = {
music: "🎵 Musik",
audiobooks: "📖 Hörbücher",
podcasts: "🎙️ 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,
filter,
group,
albums,
mode,
search,
category,
selIndex,
currentAlbumId,
gridRef,
onFilter,
onEnterGroup,
onBackToRoot,
onCategory,
onOpenAlbum,
onPlaySong,
}: Props) {
const scroller = useRef<HTMLDivElement | null>(null);
const { songs, categories, albums, total } = results;
const { songs, categories, albums: albumResults, total } = results;
const selected = total ? Math.min(selIndex, total - 1) : -1;
const isRootShelf = group === null && mode !== "tracks" && !search;
const shelves = useMemo(
() =>
GROUPS.map((shelfGroup) => ({
group: shelfGroup,
categories: categoryMatches({
albums,
search: "",
mode: "albums",
category: null,
group: shelfGroup,
}),
})),
[albums],
);
// Keep the selection on screen as the arrow keys walk past the fold.
useEffect(() => {
const box = scroller.current;
@@ -70,42 +111,96 @@ export function BrowseView({
}
}, [selected]);
if (isRootShelf) {
return (
<div ref={scroller} style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}>
{shelves.map(({ group: shelfGroup, categories: shelfCategories }) => (
<div key={shelfGroup} style={{ marginBottom: 34 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: 12,
marginBottom: 12,
}}
>
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--paper)" }}>
{GROUP_TITLE[shelfGroup]}
</div>
<button
className="round"
onClick={() => onEnterGroup(shelfGroup, null)}
aria-label={`${GROUP_TITLE[shelfGroup]} durchsuchen`}
title="Nur hier suchen"
style={{
width: 34,
height: 34,
fontSize: 15,
background: "oklch(97% 0.01 210 / .18)",
color: "var(--paper)",
}}
>
🔎
</button>
</div>
{shelfCategories.length === 0 ? (
<div
style={{
textAlign: "center",
fontSize: 14,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .6)",
}}
>
Noch nichts hier
</div>
) : (
<ShelfRow>
{shelfCategories.map((entry) => (
<CategoryTile
key={entry.key}
entry={entry}
onClick={() => onEnterGroup(shelfGroup, entry.key)}
/>
))}
</ShelfRow>
)}
</div>
))}
</div>
);
}
const showSearchBar = search.length > 0 || mode === "tracks";
const countLabel =
mode === "tracks"
? `${songs.length} Titel gefunden`
: albums.length
? `${albums.length} Album${albums.length === 1 ? "" : "en"} gefunden`
: albumResults.length
? `${albumResults.length} Album${albumResults.length === 1 ? "" : "en"} gefunden`
: "nichts gefunden";
const sectionLabel =
filter === "book" ? "Hörbücher" : filter === "music" ? "Alben" : "Alben & Hörbücher";
const categoryLabel =
filter === "book" ? "Figuren" : filter === "music" ? "Künstler" : "Figuren & Künstler";
return (
<>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
padding: "4px 32px 6px",
flex: "none",
}}
>
{FILTERS.map(([value, label]) => (
<button
key={value}
className="pill"
data-active={filter === value}
onClick={() => onFilter(value)}
>
{label}
{group !== null && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
padding: "4px 32px 6px",
flex: "none",
}}
>
<button className="pill" onClick={onBackToRoot}>
Start
</button>
))}
</div>
<div style={{ fontSize: 18, fontWeight: 900, color: "var(--paper)" }}>
{GROUP_TITLE[group]}
</div>
</div>
)}
{showSearchBar && (
<div
@@ -232,108 +327,27 @@ export function BrowseView({
</div>
)}
{categories.length > 0 && (
{categories.length > 0 && group !== null && (
<div>
<SectionTitle centered>{categoryLabel}</SectionTitle>
<SectionTitle centered>{GROUP_CATEGORY_LABEL[group]}</SectionTitle>
<div className="grid" ref={gridRef}>
{categories.map((entry, index) => {
const navIndex = songs.length + index;
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
<CategoryTile
key={entry.key}
className="card"
data-nav-index={navIndex}
data-selected={selected === navIndex}
entry={entry}
navIndex={navIndex}
selected={selected === navIndex}
onClick={() => onCategory(entry.key)}
style={{
background: allBooks
? "oklch(93% 0.055 88 / .7)"
: "oklch(95% 0.015 210 / .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}
/>
</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>
/>
);
})}
</div>
</div>
)}
{albums.length > 0 && (
{albumResults.length > 0 && (
<div>
{category !== null && !search && (
<div
@@ -357,10 +371,13 @@ export function BrowseView({
</div>
</div>
)}
{search.length > 0 && <SectionTitle centered>{sectionLabel}</SectionTitle>}
{search.length > 0 && group !== null && (
<SectionTitle centered>{GROUP_SECTION_LABEL[group]}</SectionTitle>
)}
<div className="grid" ref={gridRef}>
{albums.map((album, index) => {
{albumResults.map((album, index) => {
const navIndex = songs.length + categories.length + index;
const podcast = groupOf(album) === "podcasts";
return (
<button
key={album.id}
@@ -410,7 +427,7 @@ export function BrowseView({
marginTop: 6,
}}
>
{unitLabel(album, album.tracks.length)}
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"}
</div>
</div>
@@ -440,6 +457,172 @@ export function BrowseView({
);
}
/** 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 }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement | null>(null);
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: React.MouseEvent<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: () => 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(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .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}
/>
</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,