Files
musicmouse/web/src/components/BrowseView.tsx

920 lines
32 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 { memo, 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, effectiveSearch, 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 { useIncrementalCount } from "../hooks/useIncrementalCount";
import { PERF } from "../lib/perfSettings";
import { Cover } from "./Cover";
interface Props {
results: Results;
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,
* 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",
};
function BrowseViewImpl({
results,
group,
index,
mode,
search: rawSearch,
typed,
searchOpen,
onToggleMode,
columns,
category,
selIndex,
shelfRow,
currentAlbumId,
gridRef,
onEnterGroup,
onCategory,
onOpenAlbum,
onPlayAlbum,
onPlaySong,
}: Props) {
const scroller = useRef<HTMLDivElement | null>(null);
const { songs, categories, albums: albumResults, total } = results;
// 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`
// 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]);
// 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 (
<>
<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 countLabel =
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 (
<>
{group !== null && (
<div
style={{
textAlign: "center",
padding: "4px 32px 6px",
flex: "none",
fontSize: 15,
fontWeight: 800,
color: "var(--paper)",
}}
>
{GROUP_LABEL[group]}
</div>
)}
<SearchBox
typed={typed}
mode={mode}
open={searchOpen}
onToggleMode={onToggleMode}
note={countLabel}
/>
<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.slice(0, songWindow.visible).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>
{songWindow.visible < songs.length && (
<div ref={songWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
)}
</div>
)}
{categories.length > 0 && group !== null && (
<div>
<SectionTitle centered>{GROUP_CATEGORY_LABEL[group]}</SectionTitle>
<div className="grid" ref={gridRef}>
{categories.slice(0, categoryWindow.visible).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>
{categoryWindow.visible < categories.length && (
<div ref={categoryWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
)}
</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.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/
// 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>
{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)",
}}
>
<img
src="/dolphin-mascot.png"
alt=""
style={{
width: 120,
height: 120,
objectFit: "contain",
opacity: 0.9,
}}
/>
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}>
{search
? "Nichts gefunden probier andere Buchstaben!"
: "Tippe mindestens zwei 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 allPodcasts = entry.albums.every((a) => groupOf(a) === "podcasts");
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",
// One blurred shadow per tile: dropped under `?pi=1`, like `cardShadow`.
boxShadow: PERF.tileShadows ? "0 6px 18px var(--shadow)" : "none",
}}
>
<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}{" "}
{allPodcasts
? entry.albums.length === 1
? "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 }) {
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>
);
}
/** 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);