updates
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -9,7 +9,7 @@ const KEYS: Array<[caps: string[], label: string]> = [
|
||||
[["A-Z"], "Album oder Hörbuch suchen"],
|
||||
[["?"], "Einzelne Titel suchen"],
|
||||
[["F1"], "Diese Hilfe"],
|
||||
[["TAB"], "Musik / Hörbücher / alles"],
|
||||
[["TAB"], "Musik / Hörbücher / Podcasts"],
|
||||
[["← ↑ ↓ →"], "Auswahl bewegen"],
|
||||
[["ENTER"], "Auswahl abspielen"],
|
||||
[["ESC"], "Schließen / Suche löschen"],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/** The full-screen now-playing view. */
|
||||
|
||||
import type { Album, PlayerState } from "../api/types";
|
||||
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||
import { albumLine, isBook } from "../lib/covers";
|
||||
import { clock, remainingInAlbum } from "../lib/format";
|
||||
import { groupOf } from "../lib/search";
|
||||
import { Cover } from "./Cover";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { Transport } from "./Transport";
|
||||
@@ -11,7 +13,6 @@ import { VolumeBars } from "./VolumeBars";
|
||||
interface Props {
|
||||
state: PlayerState;
|
||||
album: Album | null;
|
||||
position: number;
|
||||
onToggle: () => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
@@ -19,12 +20,12 @@ interface Props {
|
||||
onVolume: (percent: number) => void;
|
||||
onMute: () => void;
|
||||
onBrowse: () => void;
|
||||
onOpenAlbum: () => void;
|
||||
}
|
||||
|
||||
export function PlayView({
|
||||
state,
|
||||
album,
|
||||
position,
|
||||
onToggle,
|
||||
onNext,
|
||||
onPrevious,
|
||||
@@ -32,8 +33,11 @@ export function PlayView({
|
||||
onVolume,
|
||||
onMute,
|
||||
onBrowse,
|
||||
onOpenAlbum,
|
||||
}: Props) {
|
||||
const position = usePlaybackClock(state.position, state.playing);
|
||||
const book = album ? isBook(album) : false;
|
||||
const podcast = album ? groupOf(album) === "podcasts" : false;
|
||||
const remaining = album
|
||||
? remainingInAlbum(
|
||||
album.tracks.map((track) => track.duration),
|
||||
@@ -84,13 +88,26 @@ export function PlayView({
|
||||
}}
|
||||
>
|
||||
{album ? (
|
||||
<Cover
|
||||
album={album}
|
||||
size={340}
|
||||
fit="height"
|
||||
radius={book ? "18px 34px 34px 18px" : "28px"}
|
||||
label
|
||||
/>
|
||||
<button
|
||||
onClick={onOpenAlbum}
|
||||
aria-label="Titelliste anzeigen"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<Cover
|
||||
album={album}
|
||||
size={340}
|
||||
fit="height"
|
||||
radius={book ? "18px 34px 34px 18px" : "28px"}
|
||||
label
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
@@ -144,6 +161,7 @@ export function PlayView({
|
||||
position={position}
|
||||
duration={state.duration}
|
||||
onSeek={onSeek}
|
||||
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
|
||||
height={14}
|
||||
interactive
|
||||
/>
|
||||
@@ -163,7 +181,7 @@ export function PlayView({
|
||||
{state.duration ? `−${clock(state.duration - position)}` : ""}
|
||||
</span>
|
||||
<span>
|
||||
{album ? `Noch ${clock(remaining)} ${book ? "im Hörbuch" : "im Album"}` : ""}
|
||||
{album && !podcast ? `Noch ${clock(remaining)} ${book ? "im Hörbuch" : "im Album"}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** The bar along the bottom of the browse screen. */
|
||||
|
||||
import type { Album, PlayerState } from "../api/types";
|
||||
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||
import { albumLine, isBook } from "../lib/covers";
|
||||
import { Cover } from "./Cover";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
@@ -10,7 +11,6 @@ import { VolumeBars } from "./VolumeBars";
|
||||
interface Props {
|
||||
state: PlayerState;
|
||||
album: Album | null;
|
||||
position: number;
|
||||
onToggle: () => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
@@ -23,7 +23,6 @@ interface Props {
|
||||
export function PlayerBar({
|
||||
state,
|
||||
album,
|
||||
position,
|
||||
onToggle,
|
||||
onNext,
|
||||
onPrevious,
|
||||
@@ -32,6 +31,7 @@ export function PlayerBar({
|
||||
onMute,
|
||||
onOpenPlayView,
|
||||
}: Props) {
|
||||
const position = usePlaybackClock(state.position, state.playing);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -130,6 +130,7 @@ export function PlayerBar({
|
||||
position={position}
|
||||
duration={state.duration}
|
||||
onSeek={onSeek}
|
||||
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
|
||||
height={10}
|
||||
interactive
|
||||
/>
|
||||
|
||||
@@ -8,12 +8,30 @@ interface Props {
|
||||
onSeek: (position: number) => void;
|
||||
height: number;
|
||||
interactive?: boolean;
|
||||
/** Change this when the track itself changes (not just its position) - the fill
|
||||
* snaps straight to the new position instead of visibly gliding across the whole
|
||||
* bar the way a normal, small position correction does. */
|
||||
resetKey?: string | number;
|
||||
}
|
||||
|
||||
export function ProgressBar({ position, duration, onSeek, height, interactive }: Props) {
|
||||
export function ProgressBar({
|
||||
position,
|
||||
duration,
|
||||
onSeek,
|
||||
height,
|
||||
interactive,
|
||||
resetKey,
|
||||
}: Props) {
|
||||
const track = useRef<HTMLDivElement | null>(null);
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
|
||||
// Comparing against the previous render's key, right here rather than in an effect,
|
||||
// is what lets this first paint after a track change already skip the transition -
|
||||
// an effect would only turn it off one paint too late.
|
||||
const lastResetKey = useRef(resetKey);
|
||||
const justReset = lastResetKey.current !== resetKey;
|
||||
lastResetKey.current = resetKey;
|
||||
|
||||
const positionAt = useCallback(
|
||||
(clientX: number): number => {
|
||||
const box = track.current?.getBoundingClientRect();
|
||||
@@ -61,8 +79,10 @@ export function ProgressBar({ position, duration, onSeek, height, interactive }:
|
||||
borderRadius: 999,
|
||||
background: "var(--accent)",
|
||||
width: `${percent}%`,
|
||||
// No easing while dragging, or the fill lags the finger.
|
||||
transition: dragging === null ? "width .25s linear" : "none",
|
||||
// No easing while dragging, or the fill lags the finger; none either right
|
||||
// after a track change, or it visibly glides across the whole bar back to
|
||||
// wherever the new track starts.
|
||||
transition: dragging === null && !justReset ? "width .25s linear" : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ instant - which is the point of a keyboard-first UI. Ported from the design mock
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
|
||||
export type Filter = "all" | "music" | "book";
|
||||
export type Group = "music" | "audiobooks" | "podcasts";
|
||||
export type Mode = "albums" | "tracks";
|
||||
|
||||
export interface SongHit {
|
||||
@@ -30,21 +30,28 @@ export function normalize(value: string): string {
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
export function inFilter(album: Album, filter: Filter): boolean {
|
||||
if (filter === "book") return album.kind === "book";
|
||||
if (filter === "music") return album.kind === "music";
|
||||
return true;
|
||||
/** Which of the three shelves an album belongs on. A podcast is exactly a
|
||||
* `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
|
||||
* album joins whichever of music/audiobooks matches what it actually holds. */
|
||||
export function groupOf(album: Album): Group {
|
||||
if (album.section === "Kinderpodcasts") return "podcasts";
|
||||
return album.kind === "book" ? "audiobooks" : "music";
|
||||
}
|
||||
|
||||
export function pool(albums: Album[], filter: Filter): Album[] {
|
||||
return albums.filter((album) => inFilter(album, filter));
|
||||
export function inGroup(album: Album, group: Group): boolean {
|
||||
return groupOf(album) === group;
|
||||
}
|
||||
|
||||
/** `null` means unrestricted - typing at the root searches every group at once. */
|
||||
export function pool(albums: Album[], group: Group | null): Album[] {
|
||||
return group === null ? albums : albums.filter((album) => inGroup(album, group));
|
||||
}
|
||||
|
||||
export interface BrowseQuery {
|
||||
albums: Album[];
|
||||
search: string;
|
||||
mode: Mode;
|
||||
filter: Filter;
|
||||
group: Group | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
@@ -56,9 +63,11 @@ export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories"
|
||||
}
|
||||
|
||||
export function categoryMatches(query: BrowseQuery): Category[] {
|
||||
if (listMode(query) !== "categories") return [];
|
||||
// `group === null` is the bare root screen, rendered as three shelves instead of a
|
||||
// flat category list - each shelf calls this again with its own group filled in.
|
||||
if (listMode(query) !== "categories" || query.group === null) return [];
|
||||
const map = new Map<string, Category>();
|
||||
for (const album of pool(query.albums, query.filter)) {
|
||||
for (const album of pool(query.albums, query.group)) {
|
||||
const key = album.category;
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
@@ -73,7 +82,7 @@ export function categoryMatches(query: BrowseQuery): Category[] {
|
||||
export function albumMatches(query: BrowseQuery): Album[] {
|
||||
if (query.mode === "tracks") return [];
|
||||
const needle = normalize(query.search);
|
||||
let candidates = pool(query.albums, query.filter);
|
||||
let candidates = pool(query.albums, query.group);
|
||||
if (!needle && !query.category) return [];
|
||||
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
|
||||
if (!needle) return candidates;
|
||||
@@ -87,7 +96,7 @@ export function songMatches(query: BrowseQuery): SongHit[] {
|
||||
if (query.mode !== "tracks") return [];
|
||||
const needle = normalize(query.search);
|
||||
const hits: SongHit[] = [];
|
||||
for (const album of pool(query.albums, query.filter)) {
|
||||
for (const album of pool(query.albums, query.group)) {
|
||||
album.tracks.forEach((track, index) => {
|
||||
if (!needle || normalize(track.title).includes(needle)) {
|
||||
hits.push({ album, index, title: track.title, duration: track.duration });
|
||||
|
||||
@@ -146,6 +146,28 @@ button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.shelf-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
overflow-x: auto;
|
||||
padding: 4px 4px 16px;
|
||||
cursor: grab;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* old Edge */
|
||||
}
|
||||
|
||||
.shelf-row::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari */
|
||||
}
|
||||
|
||||
.shelf-row.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.shelf-row .card {
|
||||
flex: 0 0 180px;
|
||||
}
|
||||
|
||||
.round {
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
|
||||
Reference in New Issue
Block a user