Web frontend
This commit is contained in:
189
web/src/components/AlbumModal.tsx
Normal file
189
web/src/components/AlbumModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/** The album detail sheet: cover, metadata, and a numbered track list. */
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
import { isBook, unitLabel } from "../lib/covers";
|
||||
import { clock } from "../lib/format";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
interface Props {
|
||||
album: Album;
|
||||
currentAlbumId: string | null;
|
||||
currentTrackIndex: number;
|
||||
onClose: () => void;
|
||||
onPlay: (trackIndex: number) => void;
|
||||
}
|
||||
|
||||
export function AlbumModal({
|
||||
album,
|
||||
currentAlbumId,
|
||||
currentTrackIndex,
|
||||
onClose,
|
||||
onPlay,
|
||||
}: Props) {
|
||||
const book = isBook(album);
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }} onClick={onClose}>
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
background: "oklch(96% 0.012 210)",
|
||||
borderRadius: 26,
|
||||
padding: 26,
|
||||
width: 640,
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
overflow: "auto",
|
||||
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
||||
<div style={{ width: 150, flex: "none" }}>
|
||||
<Cover album={album} size={150} radius={book ? "10px 18px 18px 10px" : "16px"} label />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 26,
|
||||
fontWeight: 900,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "oklch(45% 0.03 210 / .85)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{album.artist}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(50% 0.03 210 / .7)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{book ? "📖 Hörbuch" : "🎵 Musik"} · {unitLabel(album, album.tracks.length)} ·{" "}
|
||||
{clock(album.duration)}
|
||||
{album.figure && ` · 🧸 ${album.figure}`}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPlay(0)}
|
||||
style={{
|
||||
marginTop: 14,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
padding: "12px 22px",
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
boxShadow: "0 4px 14px oklch(70% 0.16 340 / .45)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 16,
|
||||
background: "#fff",
|
||||
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||||
}}
|
||||
/>
|
||||
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
color: "var(--ink)",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 999,
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 22 }}>
|
||||
{album.tracks.map((track, index) => {
|
||||
const current = currentAlbumId === album.id && currentTrackIndex === index;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onPlay(index)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
font: "inherit",
|
||||
textAlign: "left",
|
||||
background: current
|
||||
? "oklch(70% 0.16 340 / .16)"
|
||||
: "oklch(30% 0.03 210 / .05)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 999,
|
||||
flex: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
font: "800 13px ui-monospace, Menlo, monospace",
|
||||
background: current ? "var(--accent)" : "oklch(88% 0.02 210)",
|
||||
color: current ? "#fff" : "oklch(35% 0.03 210)",
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
|
||||
}}
|
||||
>
|
||||
{track.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
font: "700 13px ui-monospace, Menlo, monospace",
|
||||
color: "oklch(45% 0.03 210 / .7)",
|
||||
}}
|
||||
>
|
||||
{clock(track.duration)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
web/src/components/AppHeader.tsx
Normal file
39
web/src/components/AppHeader.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
||||
status?: ReactNode;
|
||||
}
|
||||
|
||||
export function AppHeader({ status }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
// The status bar is translucent in standalone mode, so make room for it.
|
||||
padding: "calc(18px + env(safe-area-inset-top)) 32px 6px",
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
alt=""
|
||||
style={{ width: 64, height: 64, objectFit: "contain" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 34,
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
|
||||
}}
|
||||
>
|
||||
Musik Delphin
|
||||
</div>
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
475
web/src/components/BrowseView.tsx
Normal file
475
web/src/components/BrowseView.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
/** 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. */
|
||||
|
||||
import { useEffect, useRef } 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 { Filter, Results, SongHit } from "../lib/search";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
interface Props {
|
||||
results: Results;
|
||||
filter: Filter;
|
||||
mode: "albums" | "tracks";
|
||||
search: string;
|
||||
category: string | null;
|
||||
selIndex: number;
|
||||
currentAlbumId: string | null;
|
||||
gridRef: (element: HTMLElement | null) => void;
|
||||
onFilter: (filter: Filter) => 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"],
|
||||
];
|
||||
|
||||
export function BrowseView({
|
||||
results,
|
||||
filter,
|
||||
mode,
|
||||
search,
|
||||
category,
|
||||
selIndex,
|
||||
currentAlbumId,
|
||||
gridRef,
|
||||
onFilter,
|
||||
onCategory,
|
||||
onOpenAlbum,
|
||||
onPlaySong,
|
||||
}: Props) {
|
||||
const scroller = useRef<HTMLDivElement | null>(null);
|
||||
const { songs, categories, albums, total } = results;
|
||||
const selected = total ? Math.min(selIndex, total - 1) : -1;
|
||||
|
||||
// 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]);
|
||||
|
||||
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`
|
||||
: "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}
|
||||
</button>
|
||||
))}
|
||||
</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}
|
||||
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}
|
||||
>
|
||||
{songs.length > 0 && (
|
||||
<div style={{ marginBottom: 30 }}>
|
||||
<SectionTitle>
|
||||
Titel <Muted>{songs.length} Treffer</Muted>
|
||||
</SectionTitle>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
maxWidth: 760,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{songs.map((hit, index) => (
|
||||
<button
|
||||
key={`${hit.album.id}-${hit.index}`}
|
||||
data-nav-index={index}
|
||||
onClick={() => onPlaySong(hit)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "10px 16px",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
background: `oklch(97% 0.01 210 / ${
|
||||
currentAlbumId === hit.album.id ? ".22" : ".10"
|
||||
})`,
|
||||
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 && (
|
||||
<div>
|
||||
<SectionTitle centered>{categoryLabel}</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
|
||||
key={entry.key}
|
||||
className="card"
|
||||
data-nav-index={navIndex}
|
||||
data-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 && (
|
||||
<div>
|
||||
{category !== null && !search && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="pill"
|
||||
data-active="true"
|
||||
onClick={() => onCategory(null)}
|
||||
>
|
||||
← Alle
|
||||
</button>
|
||||
<div style={{ fontSize: 22, fontWeight: 900, color: "var(--paper)" }}>
|
||||
{category}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{search.length > 0 && <SectionTitle centered>{sectionLabel}</SectionTitle>}
|
||||
<div className="grid" ref={gridRef}>
|
||||
{albums.map((album, index) => {
|
||||
const navIndex = songs.length + categories.length + index;
|
||||
return (
|
||||
<button
|
||||
key={album.id}
|
||||
className="card"
|
||||
data-nav-index={navIndex}
|
||||
data-selected={selected === navIndex}
|
||||
data-current={album.id === currentAlbumId}
|
||||
onClick={() => onOpenAlbum(album, navIndex)}
|
||||
style={{
|
||||
background: cardBackground(album),
|
||||
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
|
||||
boxShadow: cardShadow(album),
|
||||
}}
|
||||
>
|
||||
<Cover album={album} size={180} radius="0" label />
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px 14px",
|
||||
paddingRight: isBook(album) ? 22 : 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(30% 0.03 210)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{album.artist}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{unitLabel(album, album.tracks.length)}
|
||||
{album.figure && " · 🧸"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
children,
|
||||
centered,
|
||||
}: {
|
||||
children: React.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: React.ReactNode }) {
|
||||
return (
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
32
web/src/components/Bubbles.tsx
Normal file
32
web/src/components/Bubbles.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/** The ambient bubble field. Positions are fixed so they do not reshuffle on render. */
|
||||
|
||||
const BUBBLES: Array<[left: number, size: number, alpha: number, seconds: number, delay: number]> =
|
||||
[
|
||||
[5, 14, 0.5, 9, 0], [15, 22, 0.4, 12, 2], [28, 10, 0.5, 7, 1], [42, 18, 0.45, 10, 4],
|
||||
[58, 12, 0.5, 8, 3], [70, 26, 0.35, 13, 5], [82, 16, 0.5, 9.5, 1.5], [92, 10, 0.5, 6.5, 2.5],
|
||||
[2, 8, 0.45, 7.5, 3.5], [9, 18, 0.3, 14, 6], [21, 12, 0.5, 8.5, 5.5], [34, 24, 0.32, 12.5, 1.2],
|
||||
[38, 9, 0.5, 6.8, 4.6], [48, 14, 0.42, 10.5, 0.6], [53, 20, 0.3, 13.5, 7], [63, 8, 0.5, 7.2, 2.2],
|
||||
[66, 16, 0.38, 11.5, 5.2], [76, 11, 0.48, 9.2, 6.4], [87, 22, 0.28, 15, 3.2],
|
||||
[96, 14, 0.42, 10.8, 8], [45, 7, 0.5, 6.2, 7.6],
|
||||
];
|
||||
|
||||
export function Bubbles() {
|
||||
return (
|
||||
<>
|
||||
{BUBBLES.map(([left, size, alpha, seconds, delay], index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bubble"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
background: `oklch(90% 0.02 210 / ${alpha})`,
|
||||
animationDuration: `${seconds}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
web/src/components/Cover.tsx
Normal file
90
web/src/components/Cover.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/** Album art, or a generated stand-in built from the album's own three colours.
|
||||
*
|
||||
* The gradient sits underneath the image rather than instead of it, so a cover that is
|
||||
* still loading - or an album that has none - never shows a hole.
|
||||
*
|
||||
* The shape comes from the media type and from nowhere else: square for an album,
|
||||
* taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio.
|
||||
*/
|
||||
|
||||
import { coverUrl } from "../api/client";
|
||||
import type { Album } from "../api/types";
|
||||
import { aspectOf, coverBackground, isBook } from "../lib/covers";
|
||||
|
||||
interface Props {
|
||||
album: Album;
|
||||
/** Rendered size in px along the fixed axis; drives the stripe width so the generated
|
||||
* pattern scales with the art. */
|
||||
size: number;
|
||||
/** Which axis the parent constrains. `height` keeps rows and grids even when a book
|
||||
* and an album sit side by side. */
|
||||
fit?: "width" | "height";
|
||||
radius?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
||||
label?: boolean;
|
||||
}
|
||||
|
||||
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
|
||||
const book = isBook(album);
|
||||
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
|
||||
const box: React.CSSProperties =
|
||||
fit === "height"
|
||||
? { height: "100%", width: "auto", aspectRatio: aspectOf(album) }
|
||||
: { width: "100%", aspectRatio: aspectOf(album) };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
position: "relative",
|
||||
flex: "none",
|
||||
borderRadius: radius ?? defaultRadius,
|
||||
overflow: "hidden",
|
||||
background: coverBackground(album, Math.max(6, Math.round(size / 13))),
|
||||
...box,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{!album.has_cover && label && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: book ? "10% 14% 10% 20%" : "10%",
|
||||
textAlign: "center",
|
||||
fontSize: Math.max(11, Math.round(size / 11)),
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.15,
|
||||
color: "oklch(99% 0 0 / .92)",
|
||||
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
)}
|
||||
{album.has_cover && (
|
||||
<img
|
||||
src={coverUrl(album.id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
// Books keep a sliver of spine showing on the right, so the shelf metaphor
|
||||
// survives contact with real square artwork.
|
||||
clipPath: book ? "inset(0 5% 0 0)" : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
web/src/components/HelpOverlay.tsx
Normal file
63
web/src/components/HelpOverlay.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there,
|
||||
* it changes here. */
|
||||
|
||||
const KEYS: Array<[caps: string[], label: string]> = [
|
||||
[["LEER"], "Play / Pause"],
|
||||
[["CTRL+H", "CTRL+L"], "Song zurück / vor"],
|
||||
[["CTRL+J", "CTRL+K"], "Leiser / lauter"],
|
||||
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
|
||||
[["A-Z"], "Album oder Hörbuch suchen"],
|
||||
[["?"], "Einzelne Titel suchen"],
|
||||
[["F1"], "Diese Hilfe"],
|
||||
[["TAB"], "Musik / Hörbücher / alles"],
|
||||
[["← ↑ ↓ →"], "Auswahl bewegen"],
|
||||
[["ENTER"], "Auswahl abspielen"],
|
||||
[["ESC"], "Schließen / Suche löschen"],
|
||||
];
|
||||
|
||||
export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 5 }} onClick={onClose}>
|
||||
<div className="sheet" style={{ padding: "26px 30px", width: 330 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 800,
|
||||
color: "var(--ink)",
|
||||
marginBottom: 14,
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
⌨️ Zaubertasten
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{KEYS.map(([caps, label]) => (
|
||||
<div key={label} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{caps.map((cap) => (
|
||||
<div key={cap} className="key-cap" style={{ minWidth: caps.length > 1 ? 0 : 56 }}>
|
||||
{cap}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "oklch(35% 0.03 210)" }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 210 / .8)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Tippe irgendwohin, um zu schließen
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
web/src/components/ParentPanel.tsx
Normal file
167
web/src/components/ParentPanel.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/** Parent mode: reachable only via `?parentMode=1`, never from the child-facing UI.
|
||||
*
|
||||
* This hides the settings, it does not protect them - there is no login, and the
|
||||
* endpoints behind it are as open as the rest of the API. That matches the device's
|
||||
* threat model (a box on a home network) and is stated in the README rather than
|
||||
* implied.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Settings } from "../api/types";
|
||||
|
||||
const FIELDS: Array<[keyof Settings, string, string, number, number, number]> = [
|
||||
["min_volume", "Minimale Lautstärke", "Untergrenze am Gerät (0-200)", 0, 200, 1],
|
||||
["max_volume", "Maximale Lautstärke", "Was 100 % in der Kinder-Ansicht bedeutet", 0, 200, 1],
|
||||
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
|
||||
["volume_increment", "Schritt am Drehknopf", "Pro Rasterung", 1, 100, 1],
|
||||
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
|
||||
];
|
||||
|
||||
export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.settings()
|
||||
.then(setSettings)
|
||||
.catch((cause: unknown) => setStatus(String(cause)));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
if (!settings) return;
|
||||
setBusy(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
setSettings(await api.saveSettings(settings));
|
||||
setStatus("Gespeichert.");
|
||||
} catch {
|
||||
// The backend rejects an inverted range or an out-of-band start volume.
|
||||
setStatus("Nicht gespeichert — bitte die Werte prüfen (min ≤ Start ≤ max).");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rescan = async () => {
|
||||
setStatus("Bibliothek wird eingelesen …");
|
||||
await api.refreshLibrary();
|
||||
setStatus("Einlesen gestartet. Neue Titel erscheinen gleich von selbst.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 210 / .75)" }}>
|
||||
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||
🔧 Eltern-Einstellungen
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
color: "var(--ink)",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings === null ? (
|
||||
<div style={{ marginTop: 18, color: "oklch(45% 0.03 210)" }}>Wird geladen …</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
|
||||
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
||||
{hint}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={settings[key]}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, [key]: Number(event.target.value) })
|
||||
}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
border: "2px solid oklch(85% 0.02 210)",
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
|
||||
<button
|
||||
onClick={() => void save()}
|
||||
disabled={busy}
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void rescan()}
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
color: "var(--ink)",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
Bibliothek neu einlesen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 210)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Speichern schreibt in die <code>config.yml</code> auf dem Gerät. Die neue
|
||||
Obergrenze gilt sofort, ohne Neustart.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div style={{ marginTop: 14, fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
web/src/components/PlayView.tsx
Normal file
230
web/src/components/PlayView.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/** The full-screen now-playing view. */
|
||||
|
||||
import type { Album, PlayerState } from "../api/types";
|
||||
import { albumLine, isBook } from "../lib/covers";
|
||||
import { clock, remainingInAlbum } from "../lib/format";
|
||||
import { Cover } from "./Cover";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { Transport } from "./Transport";
|
||||
import { VolumeBars } from "./VolumeBars";
|
||||
|
||||
interface Props {
|
||||
state: PlayerState;
|
||||
album: Album | null;
|
||||
position: number;
|
||||
onToggle: () => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onSeek: (position: number) => void;
|
||||
onVolume: (percent: number) => void;
|
||||
onMute: () => void;
|
||||
onBrowse: () => void;
|
||||
}
|
||||
|
||||
export function PlayView({
|
||||
state,
|
||||
album,
|
||||
position,
|
||||
onToggle,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onSeek,
|
||||
onVolume,
|
||||
onMute,
|
||||
onBrowse,
|
||||
}: Props) {
|
||||
const book = album ? isBook(album) : false;
|
||||
const remaining = album
|
||||
? remainingInAlbum(
|
||||
album.tracks.map((track) => track.duration),
|
||||
state.track_index,
|
||||
position,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", zIndex: 1, height: "100%", minHeight: 0, overflow: "auto" }}>
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "clamp(8px, 1.8vh, 26px)",
|
||||
padding: "clamp(14px, 3.5vh, 40px) 40px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
alt=""
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "14%",
|
||||
left: 0,
|
||||
width: 140,
|
||||
height: 140,
|
||||
objectFit: "contain",
|
||||
zIndex: 0,
|
||||
pointerEvents: "none",
|
||||
opacity: 0.92,
|
||||
animation: "dolphinSwim 62s linear infinite",
|
||||
filter: "drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45))",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: "min(340px, 32vh)",
|
||||
flex: "none",
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
display: "flex",
|
||||
filter: "drop-shadow(0 20px 40px oklch(10% 0.05 210 / .55))",
|
||||
}}
|
||||
>
|
||||
{album ? (
|
||||
<Cover
|
||||
album={album}
|
||||
size={340}
|
||||
fit="height"
|
||||
radius={book ? "18px 34px 34px 18px" : "28px"}
|
||||
label
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
aspectRatio: 1,
|
||||
borderRadius: 28,
|
||||
background: "oklch(35% 0.03 210)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "center", maxWidth: 760, position: "relative", zIndex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "clamp(28px, 4.6vh, 46px)",
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
lineHeight: 1.1,
|
||||
textWrap: "pretty",
|
||||
}}
|
||||
>
|
||||
{state.track_title ?? "Wähl ein Album!"}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .8)",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{album ? albumLine(album) : "Tippen oder klicken"}
|
||||
</div>
|
||||
{album && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
color: "var(--accent-dim)",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{book ? "Kapitel" : "Song"} {state.track_index + 1} von {state.track_count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ width: "min(680px, 90%)", position: "relative", zIndex: 1 }}>
|
||||
<ProgressBar
|
||||
position={position}
|
||||
duration={state.duration}
|
||||
onSeek={onSeek}
|
||||
height={14}
|
||||
interactive
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: "oklch(88% 0.02 210 / .8)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: "ui-monospace, Menlo, monospace" }}>
|
||||
{state.duration ? `−${clock(state.duration - position)}` : ""}
|
||||
</span>
|
||||
<span>
|
||||
{album ? `Noch ${clock(remaining)} ${book ? "im Hörbuch" : "im Album"}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", zIndex: 1 }}>
|
||||
<Transport
|
||||
playing={state.playing}
|
||||
onToggle={onToggle}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
size={72}
|
||||
gap={22}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", zIndex: 1 }}>
|
||||
<VolumeBars
|
||||
volume={state.volume}
|
||||
onChange={onVolume}
|
||||
onMute={onMute}
|
||||
barWidth={18}
|
||||
barHeight="clamp(26px, 4.6vh, 44px)"
|
||||
gap={5}
|
||||
iconSize={24}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onBrowse}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 28,
|
||||
left: 32,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(97% 0.01 210 / .95)",
|
||||
borderRadius: 999,
|
||||
padding: "11px 20px",
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: "var(--ink)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
font: "800 13px ui-monospace, monospace",
|
||||
background: "var(--ink)",
|
||||
color: "#fff",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
Zurück zur Suche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
web/src/components/PlayerBar.tsx
Normal file
158
web/src/components/PlayerBar.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/** The bar along the bottom of the browse screen. */
|
||||
|
||||
import type { Album, PlayerState } from "../api/types";
|
||||
import { albumLine, isBook } from "../lib/covers";
|
||||
import { Cover } from "./Cover";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { Transport } from "./Transport";
|
||||
import { VolumeBars } from "./VolumeBars";
|
||||
|
||||
interface Props {
|
||||
state: PlayerState;
|
||||
album: Album | null;
|
||||
position: number;
|
||||
onToggle: () => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onSeek: (position: number) => void;
|
||||
onVolume: (percent: number) => void;
|
||||
onMute: () => void;
|
||||
onOpenPlayView: () => void;
|
||||
}
|
||||
|
||||
export function PlayerBar({
|
||||
state,
|
||||
album,
|
||||
position,
|
||||
onToggle,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onSeek,
|
||||
onVolume,
|
||||
onMute,
|
||||
onOpenPlayView,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 2,
|
||||
background: "oklch(18% 0.05 210 / .96)",
|
||||
backdropFilter: "blur(6px)",
|
||||
padding: "16px clamp(12px, 3vw, 32px)",
|
||||
// Clear of the iPhone home indicator when installed to the home screen.
|
||||
paddingBottom: "calc(16px + env(safe-area-inset-bottom))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "clamp(10px, 1.6vw, 22px)",
|
||||
borderTop: "3px solid oklch(70% 0.16 340 / .5)",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
alt=""
|
||||
style={{
|
||||
width: "clamp(56px, 7vw, 84px)",
|
||||
height: "clamp(56px, 7vw, 84px)",
|
||||
objectFit: "contain",
|
||||
flex: "0 1 auto",
|
||||
minWidth: 0,
|
||||
animation: state.playing ? "dolphinBob 1.6s ease-in-out infinite" : "none",
|
||||
filter: "drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5))",
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={onOpenPlayView}
|
||||
aria-label="Große Ansicht"
|
||||
style={{
|
||||
height: 56,
|
||||
width: album ? "auto" : 56,
|
||||
flex: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
cursor: album ? "pointer" : "default",
|
||||
background: album ? "none" : "oklch(35% 0.03 210)",
|
||||
}}
|
||||
>
|
||||
{album && <Cover album={album} size={56} fit="height" radius="10px" />}
|
||||
</button>
|
||||
|
||||
<div style={{ flex: "0 1 220px", minWidth: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "var(--paper)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{state.track_title ?? "Wähl ein Album!"}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "oklch(85% 0.02 210 / .75)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{album ? albumLine(album) : "Tippen oder klicken"}
|
||||
</div>
|
||||
{album && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "var(--accent-dim)",
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{isBook(album) ? "Kapitel" : "Song"} {state.track_index + 1} von{" "}
|
||||
{state.track_count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: "1 1 auto", maxWidth: 360, minWidth: 90 }}>
|
||||
<ProgressBar
|
||||
position={position}
|
||||
duration={state.duration}
|
||||
onSeek={onSeek}
|
||||
height={10}
|
||||
interactive
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transport
|
||||
playing={state.playing}
|
||||
onToggle={onToggle}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
size={48}
|
||||
gap={10}
|
||||
/>
|
||||
|
||||
<VolumeBars
|
||||
volume={state.volume}
|
||||
onChange={onVolume}
|
||||
onMute={onMute}
|
||||
barWidth={11}
|
||||
barHeight="30px"
|
||||
gap={3}
|
||||
iconSize={18}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
web/src/components/ProgressBar.tsx
Normal file
70
web/src/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/** The progress bar, and the one thing the mockup could not do: scrubbing. */
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
position: number;
|
||||
duration: number;
|
||||
onSeek: (position: number) => void;
|
||||
height: number;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function ProgressBar({ position, duration, onSeek, height, interactive }: Props) {
|
||||
const track = useRef<HTMLDivElement | null>(null);
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
|
||||
const positionAt = useCallback(
|
||||
(clientX: number): number => {
|
||||
const box = track.current?.getBoundingClientRect();
|
||||
if (!box || !duration) return 0;
|
||||
const ratio = Math.min(1, Math.max(0, (clientX - box.left) / box.width));
|
||||
return ratio * duration;
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
const shown = dragging ?? position;
|
||||
const percent = duration > 0 ? Math.min(100, (shown / duration) * 100) : 0;
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!interactive || !duration) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragging(positionAt(event.clientX));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={track}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={(event) => {
|
||||
if (dragging !== null) setDragging(positionAt(event.clientX));
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (dragging === null) return;
|
||||
const target = positionAt(event.clientX);
|
||||
setDragging(null);
|
||||
onSeek(target);
|
||||
}}
|
||||
style={{
|
||||
height,
|
||||
borderRadius: 999,
|
||||
background: "oklch(30% 0.03 210 / .6)",
|
||||
overflow: "hidden",
|
||||
cursor: interactive && duration ? "pointer" : "default",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background: "var(--accent)",
|
||||
width: `${percent}%`,
|
||||
// No easing while dragging, or the fill lags the finger.
|
||||
transition: dragging === null ? "width .25s linear" : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
web/src/components/Transport.tsx
Normal file
94
web/src/components/Transport.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/** The three transport buttons, at two sizes. The glyphs are CSS shapes, as in the
|
||||
* mockup - no icon font to load on a device that boots offline. */
|
||||
|
||||
interface Props {
|
||||
playing: boolean;
|
||||
onToggle: () => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
/** Side length of the skip buttons; the play button scales from it. */
|
||||
size: number;
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }: Props) {
|
||||
const glyph = Math.round(size * 0.33);
|
||||
const bar = Math.max(4, Math.round(size * 0.085));
|
||||
const playSize = Math.round(size * 1.26);
|
||||
const playBar = Math.max(6, Math.round(playSize * 0.1));
|
||||
const playGlyph = Math.round(playSize * 0.37);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap }}>
|
||||
<button
|
||||
className="round"
|
||||
onClick={onPrevious}
|
||||
aria-label="Vorheriger Titel"
|
||||
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<span style={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
|
||||
<span
|
||||
style={{
|
||||
width: glyph,
|
||||
height: glyph,
|
||||
background: "#fff",
|
||||
clipPath: "polygon(100% 0%, 100% 100%, 0% 50%)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="round"
|
||||
onClick={onToggle}
|
||||
aria-label={playing ? "Pause" : "Abspielen"}
|
||||
style={{
|
||||
width: playSize,
|
||||
height: playSize,
|
||||
background: "var(--accent)",
|
||||
boxShadow: "0 6px 20px oklch(70% 0.16 340 / .5)",
|
||||
}}
|
||||
>
|
||||
{playing ? (
|
||||
<span style={{ display: "flex", gap: playBar * 0.8 }}>
|
||||
<span
|
||||
style={{ width: playBar, height: playGlyph, borderRadius: 2, background: "#fff" }}
|
||||
/>
|
||||
<span
|
||||
style={{ width: playBar, height: playGlyph, borderRadius: 2, background: "#fff" }}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
width: playGlyph,
|
||||
height: playGlyph,
|
||||
background: "#fff",
|
||||
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="round"
|
||||
onClick={onNext}
|
||||
aria-label="Nächster Titel"
|
||||
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<span
|
||||
style={{
|
||||
width: glyph,
|
||||
height: glyph,
|
||||
background: "#fff",
|
||||
clipPath: "polygon(0% 0%, 0% 100%, 100% 50%)",
|
||||
}}
|
||||
/>
|
||||
<span style={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
web/src/components/VolumeBars.tsx
Normal file
62
web/src/components/VolumeBars.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/** Five bars, not a slider. The device's real range never reaches the browser: 100 %
|
||||
* here means whatever ceiling a parent configured. */
|
||||
|
||||
interface Props {
|
||||
/** 0..100. */
|
||||
volume: number;
|
||||
onChange: (percent: number) => void;
|
||||
onMute: () => void;
|
||||
barWidth: number;
|
||||
barHeight: string;
|
||||
gap: number;
|
||||
iconSize: number;
|
||||
}
|
||||
|
||||
export function VolumeBars({
|
||||
volume,
|
||||
onChange,
|
||||
onMute,
|
||||
barWidth,
|
||||
barHeight,
|
||||
gap,
|
||||
iconSize,
|
||||
}: Props) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<button
|
||||
onClick={onMute}
|
||||
title="Stumm"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
fontSize: iconSize,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{volume === 0 ? "🔇" : "🔊"}
|
||||
</button>
|
||||
<div style={{ display: "flex", gap }}>
|
||||
{[20, 40, 60, 80, 100].map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => onChange(level)}
|
||||
title={`Lautstärke ${level} %`}
|
||||
aria-label={`Lautstärke ${level} Prozent`}
|
||||
style={{
|
||||
width: barWidth,
|
||||
height: barHeight,
|
||||
borderRadius: 5,
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
background:
|
||||
volume >= level ? "var(--accent)" : "oklch(88% 0.02 210 / .4)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user