/** 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 = { music: "Künstler", audiobooks: "Figuren", podcasts: "Sendungen", }; /** Heading over the album grid while searching inside a group. */ const GROUP_SECTION_LABEL: Record = { 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(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(`[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 (
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
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) && (
{SHOW_ROW_ICONS && {GROUP_ICON[shelfGroup]}} {SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]} {SHOW_ROW_TITLES && }
)} {shelfCategories.length === 0 ? (
Noch nichts hier
) : ( {shelfCategories.map((entry, tileIndex) => ( { // 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); }} /> ))} )}
))}
); } 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 && (
{GROUP_LABEL[group]}
)} {showSearchBar && (
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"} {search ? `"${search}"` : "tippe …"}
{countLabel}
)}
{songs.length > 0 && (
Titel {songs.length} Treffer
{songs.map((hit, index) => ( ))}
)} {categories.length > 0 && group !== null && (
{GROUP_CATEGORY_LABEL[group]}
{categories.map((entry, index) => { const navIndex = songs.length + index; return ( onCategory(entry.key)} /> ); })}
)} {albumResults.length > 0 && (
{category !== null && !search && (
{category}
)} {search.length > 0 && group !== null && ( {GROUP_SECTION_LABEL[group]} )}
{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 (
); })}
)} {total === 0 && (
Nichts gefunden — probier andere Buchstaben!
)}
); } /** 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(null); useEffect(() => { const element = ref.current; if (!element || focusedIndex === null) return; const tile = element.querySelector(`[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) => { 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 (
{children}
); } /** 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) => 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 ( ); } function SectionTitle({ children, centered, }: { children: ReactNode; centered?: boolean; }) { return (
{children}
); } function Muted({ children }: { children: ReactNode }) { return ( {children} ); }