Web frontend
This commit is contained in:
322
web/src/App.tsx
Normal file
322
web/src/App.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
/** Owns the browse state, mounts the keyboard handler, and switches between views.
|
||||
*
|
||||
* The split that matters: everything about *what is playing* comes from the backend
|
||||
* over the websocket, and everything about *what you are looking at* lives here. The
|
||||
* mockup kept both in one object; a real player has other front-ends - the buttons on
|
||||
* the mouse, a figure on the reader, Home Assistant - and this UI has to follow them.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { api } from "./api/client";
|
||||
import type { Album } from "./api/types";
|
||||
import { AlbumModal } from "./components/AlbumModal";
|
||||
import { AppHeader } from "./components/AppHeader";
|
||||
import { BrowseView } from "./components/BrowseView";
|
||||
import { Bubbles } from "./components/Bubbles";
|
||||
import { HelpOverlay } from "./components/HelpOverlay";
|
||||
import { ParentPanel } from "./components/ParentPanel";
|
||||
import { PlayerBar } from "./components/PlayerBar";
|
||||
import { PlayView } from "./components/PlayView";
|
||||
import { useGridColumns } from "./hooks/useGridColumns";
|
||||
import { useLibrary } from "./hooks/useLibrary";
|
||||
import { usePlaybackClock } from "./hooks/usePlaybackClock";
|
||||
import { usePlayerState } from "./hooks/usePlayerState";
|
||||
import type { Action, UiState } from "./lib/keyboard";
|
||||
import { handleKey, initialUiState } from "./lib/keyboard";
|
||||
import { playPop } from "./lib/pop";
|
||||
import type { Filter, Results, SongHit } from "./lib/search";
|
||||
import { results as computeResults } from "./lib/search";
|
||||
|
||||
/** Volume when un-muting, matching the mockup. */
|
||||
const UNMUTE_PERCENT = 60;
|
||||
|
||||
export function App() {
|
||||
const [ui, setUi] = useState<UiState>(initialUiState);
|
||||
const library = useLibrary();
|
||||
const connection = usePlayerState(library.reload);
|
||||
const [gridRef, columns] = useGridColumns();
|
||||
const [parentMode, setParentMode] = useState(
|
||||
() => new URLSearchParams(location.search).get("parentMode") === "1",
|
||||
);
|
||||
|
||||
const state = connection.state;
|
||||
const position = usePlaybackClock(state?.position ?? 0, state?.playing ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
|
||||
}, [columns]);
|
||||
|
||||
const results: Results = useMemo(
|
||||
() =>
|
||||
computeResults({
|
||||
albums: library.albums,
|
||||
search: ui.search,
|
||||
mode: ui.mode,
|
||||
filter: ui.filter,
|
||||
category: ui.category,
|
||||
}),
|
||||
[library.albums, ui.search, ui.mode, ui.filter, ui.category],
|
||||
);
|
||||
|
||||
const byId = useMemo(
|
||||
() => new Map(library.albums.map((album) => [album.id, album])),
|
||||
[library.albums],
|
||||
);
|
||||
const currentAlbum: Album | null = state?.album_id ? (byId.get(state.album_id) ?? null) : null;
|
||||
const openAlbum: Album | null = ui.openAlbumId ? (byId.get(ui.openAlbumId) ?? null) : null;
|
||||
|
||||
const play = useCallback((albumId: string, trackIndex: number) => {
|
||||
playPop(300);
|
||||
setUi((previous) => ({ ...previous, view: "play", openAlbumId: null }));
|
||||
void api.play(albumId, trackIndex);
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback(
|
||||
(percent: number) => {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
// Optimistic, so the bars move on the keypress rather than a frame later.
|
||||
connection.optimistic({ volume: clamped });
|
||||
void api.setVolume(clamped);
|
||||
},
|
||||
[connection],
|
||||
);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!state) return;
|
||||
if (state.album_id === null) {
|
||||
// Nothing loaded yet: space starts whatever is on screen, as in the mockup.
|
||||
const first = results.albums[0] ?? library.albums[0];
|
||||
if (first) play(first.id, 0);
|
||||
return;
|
||||
}
|
||||
connection.optimistic({ playing: !state.playing });
|
||||
void (state.playing ? api.pause() : api.resume());
|
||||
}, [connection, library.albums, play, results.albums, state]);
|
||||
|
||||
/** The one place a keyboard action, a click or a tap all end up. */
|
||||
const run = useCallback(
|
||||
(actions: Action[]) => {
|
||||
for (const action of actions) {
|
||||
switch (action.type) {
|
||||
case "ui":
|
||||
setUi((previous) => ({ ...previous, ...action.patch }));
|
||||
break;
|
||||
case "play":
|
||||
play(action.albumId, action.trackIndex);
|
||||
break;
|
||||
case "toggle":
|
||||
toggle();
|
||||
break;
|
||||
case "next":
|
||||
playPop(260);
|
||||
void api.next();
|
||||
break;
|
||||
case "previous":
|
||||
playPop(260);
|
||||
void api.previous();
|
||||
break;
|
||||
case "volume":
|
||||
setVolume((state?.volume ?? 0) + action.delta);
|
||||
break;
|
||||
case "seek":
|
||||
if (state?.duration) {
|
||||
void api.seek(Math.max(0, Math.min(state.duration, position + action.delta)));
|
||||
}
|
||||
break;
|
||||
case "pop":
|
||||
playPop(action.freq);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[play, position, setVolume, state, toggle],
|
||||
);
|
||||
|
||||
// Held in a ref so the listener is installed once rather than on every state change.
|
||||
const latest = useRef({ ui, results, run });
|
||||
latest.current = { ui, results, run };
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
// Parent mode has real text fields; typing a title into one must not start a song.
|
||||
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
|
||||
|
||||
const current = latest.current;
|
||||
const actions = handleKey(event, current.ui, current.results);
|
||||
if (!actions.length) return;
|
||||
event.preventDefault();
|
||||
current.run(actions);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
const onFilter = (filter: Filter) => {
|
||||
playPop(380);
|
||||
setUi((previous) => ({
|
||||
...previous,
|
||||
filter,
|
||||
selIndex: 0,
|
||||
view: "browse",
|
||||
category: null,
|
||||
}));
|
||||
};
|
||||
|
||||
const onCategory = (key: string | null) => {
|
||||
if (key) playPop(440);
|
||||
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
|
||||
};
|
||||
|
||||
const onOpenAlbum = (album: Album, navIndex: number) => {
|
||||
playPop(420);
|
||||
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex }));
|
||||
};
|
||||
|
||||
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
|
||||
const onSeek = (target: number) => void api.seek(target);
|
||||
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
|
||||
|
||||
return (
|
||||
<div className="stage">
|
||||
<Bubbles />
|
||||
|
||||
{ui.view === "browse" && state && (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AppHeader status={<ConnectionDot online={connection.online} state={state} />} />
|
||||
<BrowseView
|
||||
results={results}
|
||||
filter={ui.filter}
|
||||
mode={ui.mode}
|
||||
search={ui.search}
|
||||
category={ui.category}
|
||||
selIndex={ui.selIndex}
|
||||
currentAlbumId={state.album_id}
|
||||
gridRef={gridRef}
|
||||
onFilter={onFilter}
|
||||
onCategory={onCategory}
|
||||
onOpenAlbum={onOpenAlbum}
|
||||
onPlaySong={onPlaySong}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ui.view === "play" && state && (
|
||||
<PlayView
|
||||
state={state}
|
||||
album={currentAlbum}
|
||||
position={position}
|
||||
onToggle={toggle}
|
||||
onNext={() => run([{ type: "next" }])}
|
||||
onPrevious={() => run([{ type: "previous" }])}
|
||||
onSeek={onSeek}
|
||||
onVolume={setVolume}
|
||||
onMute={onMute}
|
||||
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openAlbum && state && (
|
||||
<AlbumModal
|
||||
album={openAlbum}
|
||||
currentAlbumId={state.album_id}
|
||||
currentTrackIndex={state.track_index}
|
||||
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
|
||||
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setUi((previous) => ({ ...previous, showHelp: !previous.showHelp }))}
|
||||
title="Zaubertasten (F1)"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 18,
|
||||
right: 24,
|
||||
zIndex: 3,
|
||||
width: 44,
|
||||
height: 44,
|
||||
background: "oklch(97% 0.01 210 / .95)",
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
color: "var(--ink)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{ui.showHelp && (
|
||||
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
||||
)}
|
||||
|
||||
{ui.view === "browse" && state && (
|
||||
<PlayerBar
|
||||
state={state}
|
||||
album={currentAlbum}
|
||||
position={position}
|
||||
onToggle={toggle}
|
||||
onNext={() => run([{ type: "next" }])}
|
||||
onPrevious={() => run([{ type: "previous" }])}
|
||||
onSeek={onSeek}
|
||||
onVolume={setVolume}
|
||||
onMute={onMute}
|
||||
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
|
||||
|
||||
{(!state || library.loading) && <Splash error={library.error} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) {
|
||||
const label = !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null;
|
||||
if (!label) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: online ? "var(--paper)" : "oklch(75% 0.16 30)",
|
||||
background: "oklch(97% 0.01 210 / .16)",
|
||||
padding: "6px 14px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Splash({ error }: { error: string | null }) {
|
||||
return (
|
||||
<div
|
||||
className="overlay"
|
||||
style={{ zIndex: 10, background: "oklch(20% 0.045 210 / .92)", flexDirection: "column", gap: 18 }}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
alt=""
|
||||
style={{ width: 140, height: 140, objectFit: "contain", animation: "dolphinBob 1.6s ease-in-out infinite" }}
|
||||
/>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: "var(--paper)" }}>
|
||||
{error ? "Der Delphin ist nicht erreichbar …" : "Einen Moment …"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
web/src/api/client.ts
Normal file
43
web/src/api/client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
|
||||
|
||||
import type { Album, PlayerState, Settings } from "./types";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
|
||||
...init,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${init?.method ?? "GET"} ${path} failed: ${response.status}`);
|
||||
}
|
||||
return response.status === 204 ? (undefined as T) : ((await response.json()) as T);
|
||||
}
|
||||
|
||||
function post(path: string, body?: unknown): Promise<void> {
|
||||
return request<void>(path, {
|
||||
method: "POST",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
|
||||
state: () => request<PlayerState>("/state"),
|
||||
refreshLibrary: () => post("/library/refresh"),
|
||||
|
||||
play: (albumId: string, trackIndex = 0) =>
|
||||
post("/play", { album_id: albumId, track_index: trackIndex }),
|
||||
resume: () => post("/resume"),
|
||||
pause: () => post("/pause"),
|
||||
next: () => post("/next"),
|
||||
previous: () => post("/previous"),
|
||||
seek: (position: number) => post("/seek", { position }),
|
||||
setVolume: (percent: number) => post("/volume", { percent }),
|
||||
nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }),
|
||||
|
||||
settings: () => request<Settings>("/settings"),
|
||||
saveSettings: (settings: Settings) =>
|
||||
request<Settings>("/settings", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
};
|
||||
|
||||
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;
|
||||
65
web/src/api/types.ts
Normal file
65
web/src/api/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/** The shapes the backend serves. Mirrors `musicmouse/services/web/schemas.py`. */
|
||||
|
||||
export type AlbumKind = "music" | "book";
|
||||
|
||||
export interface TrackAnalysis {
|
||||
tempo: number | null;
|
||||
energy: number | null;
|
||||
valence: number | null;
|
||||
brightness: number | null;
|
||||
beats: boolean;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
title: string;
|
||||
/** Seconds, read from the file's tags at scan time. */
|
||||
duration: number;
|
||||
analysis: TrackAnalysis | null;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
section: string;
|
||||
kind: AlbumKind;
|
||||
title: string;
|
||||
artist: string;
|
||||
series: string | null;
|
||||
figure: string | null;
|
||||
/** Series for audiobooks, artist for music. What the browse view groups by. */
|
||||
category: string;
|
||||
/** Three `#rrggbb`, taken from the cover art or synthesised: primary, secondary, accent. */
|
||||
colors: string[];
|
||||
has_cover: boolean;
|
||||
duration: number;
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
playing: boolean;
|
||||
album_id: string | null;
|
||||
album_title: string | null;
|
||||
artist: string | null;
|
||||
kind: AlbumKind | null;
|
||||
track_index: number;
|
||||
track_title: string | null;
|
||||
track_count: number;
|
||||
position: number;
|
||||
duration: number;
|
||||
/** Percent, 0..100. The device's configured range never leaves the backend. */
|
||||
volume: number;
|
||||
active_figure: string | null;
|
||||
connected: { firmware: boolean; mqtt: boolean };
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
min_volume: number;
|
||||
max_volume: number;
|
||||
initial_volume: number;
|
||||
volume_increment: number;
|
||||
button_leds_brightness: number;
|
||||
}
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "state"; state: PlayerState }
|
||||
| { type: "position"; position: number; duration: number }
|
||||
| { type: "library" };
|
||||
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>
|
||||
);
|
||||
}
|
||||
40
web/src/hooks/useGridColumns.ts
Normal file
40
web/src/hooks/useGridColumns.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** How many columns the album grid actually has.
|
||||
|
||||
Arrow-key navigation has to move a whole row at a time, and the grid is
|
||||
`repeat(auto-fill, minmax(180px, 1fr))` - so the count is whatever the browser worked
|
||||
out, not something the code can assume.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useGridColumns(): [(element: HTMLElement | null) => void, number] {
|
||||
const [columns, setColumns] = useState(4);
|
||||
const element = useRef<HTMLElement | null>(null);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const grid = element.current;
|
||||
if (!grid) return;
|
||||
const count = getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length;
|
||||
if (count) setColumns((previous) => (count === previous ? previous : count));
|
||||
}, []);
|
||||
|
||||
const ref = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
element.current = node;
|
||||
if (node) requestAnimationFrame(measure);
|
||||
},
|
||||
[measure],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(measure);
|
||||
if (element.current) observer.observe(element.current);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, [measure]);
|
||||
|
||||
return [ref, columns];
|
||||
}
|
||||
38
web/src/hooks/useLibrary.ts
Normal file
38
web/src/hooks/useLibrary.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** The whole album index, fetched once.
|
||||
|
||||
At roughly 90 kB for a real collection this is cheaper than a search endpoint and makes
|
||||
type-to-search instant, which is what the keyboard-first design needs.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Album } from "../api/types";
|
||||
|
||||
export interface Library {
|
||||
albums: Album[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export function useLibrary(): Library {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api
|
||||
.library()
|
||||
.then((loaded) => {
|
||||
setAlbums(loaded);
|
||||
setError(null);
|
||||
})
|
||||
.catch((cause: unknown) => setError(String(cause)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
return { albums, loading, error, reload };
|
||||
}
|
||||
33
web/src/hooks/usePlaybackClock.ts
Normal file
33
web/src/hooks/usePlaybackClock.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** A smooth playback position, from a position that only arrives twice a second.
|
||||
|
||||
Driving a progress bar straight off the websocket visibly steps. This interpolates
|
||||
between frames on every animation frame and re-seeds whenever a real one lands, which
|
||||
also gives beat-synced animation the frame-accurate clock it will need later.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function usePlaybackClock(position: number, playing: boolean): number {
|
||||
const [interpolated, setInterpolated] = useState(position);
|
||||
const anchor = useRef({ position, at: performance.now() });
|
||||
|
||||
// A new server frame is the truth; restart the interpolation from it.
|
||||
useEffect(() => {
|
||||
anchor.current = { position, at: performance.now() };
|
||||
setInterpolated(position);
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let frame = 0;
|
||||
const tick = () => {
|
||||
const { position: base, at } = anchor.current;
|
||||
setInterpolated(base + (performance.now() - at) / 1000);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [playing]);
|
||||
|
||||
return playing ? interpolated : position;
|
||||
}
|
||||
82
web/src/hooks/usePlayerState.ts
Normal file
82
web/src/hooks/usePlayerState.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/** The live connection to the backend.
|
||||
|
||||
State arrives over a websocket that only pushes: every command goes out as a plain
|
||||
POST. A reconnect reseeds from `GET /api/state` rather than assuming the UI's copy is
|
||||
still right, and `onLibraryChanged` fires when a rescan has finished.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { PlayerState, ServerMessage } from "../api/types";
|
||||
|
||||
const RECONNECT_DELAY_MS = 1500;
|
||||
|
||||
export interface Connection {
|
||||
state: PlayerState | null;
|
||||
online: boolean;
|
||||
/** Apply a change locally so a keypress feels instant; the next frame reconciles. */
|
||||
optimistic: (patch: Partial<PlayerState>) => void;
|
||||
}
|
||||
|
||||
export function usePlayerState(onLibraryChanged: () => void): Connection {
|
||||
const [state, setState] = useState<PlayerState | null>(null);
|
||||
const [online, setOnline] = useState(false);
|
||||
|
||||
// Kept in a ref so reconnecting never re-runs the effect that owns the socket.
|
||||
const libraryChanged = useRef(onLibraryChanged);
|
||||
libraryChanged.current = onLibraryChanged;
|
||||
|
||||
useEffect(() => {
|
||||
let socket: WebSocket | null = null;
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
let closed = false;
|
||||
|
||||
const connect = () => {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
socket = new WebSocket(`${protocol}//${location.host}/api/ws`);
|
||||
|
||||
socket.onopen = () => {
|
||||
setOnline(true);
|
||||
// The server sends a snapshot on connect, but a reconnect may have missed
|
||||
// changes in between, so ask for the truth as well.
|
||||
void api.state().then(setState).catch(() => undefined);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data as string) as ServerMessage;
|
||||
if (message.type === "state") {
|
||||
setState(message.state);
|
||||
} else if (message.type === "position") {
|
||||
setState((previous) =>
|
||||
previous
|
||||
? { ...previous, position: message.position, duration: message.duration }
|
||||
: previous,
|
||||
);
|
||||
} else if (message.type === "library") {
|
||||
libraryChanged.current();
|
||||
}
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
setOnline(false);
|
||||
if (!closed) retry = setTimeout(connect, RECONNECT_DELAY_MS);
|
||||
};
|
||||
socket.onclose = reconnect;
|
||||
socket.onerror = () => socket?.close();
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
closed = true;
|
||||
clearTimeout(retry);
|
||||
socket?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const optimistic = useCallback((patch: Partial<PlayerState>) => {
|
||||
setState((previous) => (previous ? { ...previous, ...patch } : previous));
|
||||
}, []);
|
||||
|
||||
return { state, online, optimistic };
|
||||
}
|
||||
34
web/src/lib/__tests__/format.test.ts
Normal file
34
web/src/lib/__tests__/format.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { clock, remainingInAlbum } from "../format";
|
||||
|
||||
describe("clock", () => {
|
||||
it("formats minutes and seconds", () => {
|
||||
expect(clock(0)).toBe("0:00");
|
||||
expect(clock(9)).toBe("0:09");
|
||||
expect(clock(200.5)).toBe("3:21");
|
||||
});
|
||||
|
||||
it("grows an hours field for a long audiobook", () => {
|
||||
expect(clock(3661)).toBe("1:01:01");
|
||||
});
|
||||
|
||||
it("never shows negative time", () => {
|
||||
expect(clock(-5)).toBe("0:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remainingInAlbum", () => {
|
||||
it("counts the rest of this track plus every track after it", () => {
|
||||
expect(remainingInAlbum([60, 30, 10], 0, 20)).toBe(80);
|
||||
expect(remainingInAlbum([60, 30, 10], 2, 0)).toBe(10);
|
||||
});
|
||||
|
||||
it("copes with a position past the end of the track", () => {
|
||||
expect(remainingInAlbum([60, 30], 0, 999)).toBe(30);
|
||||
});
|
||||
|
||||
it("returns zero for an empty album", () => {
|
||||
expect(remainingInAlbum([], 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
224
web/src/lib/__tests__/keyboard.test.ts
Normal file
224
web/src/lib/__tests__/keyboard.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Album } from "../../api/types";
|
||||
import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard";
|
||||
import { normalize, results as computeResults } from "../search";
|
||||
|
||||
function album(id: string, over: Partial<Album> = {}): Album {
|
||||
return {
|
||||
id,
|
||||
section: "Musik",
|
||||
kind: "music",
|
||||
title: `Album ${id}`,
|
||||
artist: "Kinderparty",
|
||||
series: null,
|
||||
figure: null,
|
||||
category: "Kinderparty",
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
tracks: [
|
||||
{ title: `Lied ${id}`, duration: 60, analysis: null },
|
||||
{ title: "Zweites Lied", duration: 60, analysis: null },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const ALBUMS: Album[] = [
|
||||
album("a"),
|
||||
album("b", { title: "Hörbuch Conni", artist: "Conni", kind: "book", series: "Conni", category: "Conni" }),
|
||||
album("c", { artist: "Rolf", category: "Rolf" }),
|
||||
];
|
||||
|
||||
const key = (k: string, over: Partial<Parameters<typeof handleKey>[0]> = {}) => ({
|
||||
key: k,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const resultsFor = (ui: UiState) =>
|
||||
computeResults({
|
||||
albums: ALBUMS,
|
||||
search: ui.search,
|
||||
mode: ui.mode,
|
||||
filter: ui.filter,
|
||||
category: ui.category,
|
||||
});
|
||||
|
||||
const press = (k: string, ui: UiState = initialUiState, over = {}) =>
|
||||
handleKey(key(k, over), ui, resultsFor(ui));
|
||||
|
||||
describe("normalize", () => {
|
||||
it("strips case, punctuation and diacritics so a child's letters match", () => {
|
||||
expect(normalize("Hörbücher, Folge 2!")).toBe("horbucherfolge2");
|
||||
expect(normalize("Käpt'n Krabbe")).toBe("kaptnkrabbe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("shows categories with no query, albums once there is one", () => {
|
||||
expect(resultsFor(initialUiState).categories).toHaveLength(3);
|
||||
expect(resultsFor(initialUiState).albums).toHaveLength(0);
|
||||
|
||||
const searching = { ...initialUiState, search: "conni" };
|
||||
expect(resultsFor(searching).albums.map((a) => a.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("filters books and music apart", () => {
|
||||
const books = resultsFor({ ...initialUiState, filter: "book", search: "a" });
|
||||
expect(books.albums.every((a) => a.kind === "book")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches title and artist together", () => {
|
||||
expect(resultsFor({ ...initialUiState, search: "rolf" }).albums.map((a) => a.id)).toEqual(["c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard", () => {
|
||||
it("types letters into the search", () => {
|
||||
expect(press("k")).toEqual([
|
||||
{ type: "ui", patch: { search: "k", view: "browse", selIndex: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts umlauts, which the mockup's a-z0-9 test rejected", () => {
|
||||
expect(press("ö")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores keys that mean nothing, so the browser keeps its own", () => {
|
||||
expect(press("F5")).toEqual([]);
|
||||
expect(press("r", { ...initialUiState }, { ctrlKey: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps the ctrl chords to transport and volume", () => {
|
||||
expect(press("l", initialUiState, { ctrlKey: true })).toEqual([{ type: "next" }]);
|
||||
expect(press("h", initialUiState, { ctrlKey: true })).toEqual([{ type: "previous" }]);
|
||||
expect(press("k", initialUiState, { ctrlKey: true })).toEqual([
|
||||
{ type: "volume", delta: 10 },
|
||||
]);
|
||||
expect(press("j", initialUiState, { ctrlKey: true })).toEqual([
|
||||
{ type: "volume", delta: -10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("cycles the filter with TAB", () => {
|
||||
expect(press("Tab")).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { filter: "music", selIndex: 0, view: "browse", category: null },
|
||||
});
|
||||
expect(press("Tab", { ...initialUiState, filter: "book" })).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { filter: "all", selIndex: 0, view: "browse", category: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("gives arrows to navigation while browsing and to transport while playing", () => {
|
||||
expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
|
||||
|
||||
const playing: UiState = { ...initialUiState, view: "play" };
|
||||
expect(press("ArrowRight", playing)).toEqual([{ type: "next" }]);
|
||||
expect(press("ArrowUp", playing)).toEqual([{ type: "volume", delta: 10 }]);
|
||||
expect(press("ArrowDown", playing)).toEqual([{ type: "volume", delta: -10 }]);
|
||||
});
|
||||
|
||||
it("seeks with shift+arrows in either view", () => {
|
||||
expect(press("ArrowRight", initialUiState, { shiftKey: true })).toEqual([
|
||||
{ type: "seek", delta: 15 },
|
||||
]);
|
||||
expect(press("ArrowLeft", { ...initialUiState, view: "play" }, { shiftKey: true })).toEqual([
|
||||
{ type: "seek", delta: -15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a whole row at a time in the grid", () => {
|
||||
const ui = { ...initialUiState, cols: 2, selIndex: 0 };
|
||||
expect(handleKey(key("ArrowDown"), ui, resultsFor(ui))).toEqual([
|
||||
{ type: "ui", patch: { selIndex: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clamps the selection to what is on screen", () => {
|
||||
const ui = { ...initialUiState, selIndex: 2 };
|
||||
expect(handleKey(key("ArrowRight"), ui, resultsFor(ui))).toEqual([
|
||||
{ type: "ui", patch: { selIndex: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("peels one layer at a time with ESC", () => {
|
||||
const deep: UiState = {
|
||||
...initialUiState,
|
||||
showHelp: true,
|
||||
openAlbumId: "a",
|
||||
search: "x",
|
||||
mode: "tracks",
|
||||
category: "Conni",
|
||||
};
|
||||
expect(press("Escape", deep)).toEqual([
|
||||
{ type: "ui", patch: { showHelp: false, openAlbumId: null } },
|
||||
]);
|
||||
|
||||
const searching = { ...deep, showHelp: false, openAlbumId: null };
|
||||
expect(press("Escape", searching)).toEqual([
|
||||
{ type: "ui", patch: { search: "", selIndex: 0 } },
|
||||
]);
|
||||
|
||||
const inTracks = { ...searching, search: "" };
|
||||
expect(press("Escape", inTracks)).toEqual([
|
||||
{ type: "ui", patch: { mode: "albums", selIndex: 0 } },
|
||||
]);
|
||||
|
||||
const inCategory = { ...inTracks, mode: "albums" as const };
|
||||
expect(press("Escape", inCategory)).toEqual([
|
||||
{ type: "ui", patch: { category: null, selIndex: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not swallow backspace when there is nothing to delete", () => {
|
||||
expect(press("Backspace")).toEqual([]);
|
||||
expect(press("Backspace", { ...initialUiState, search: "ab" })).toEqual([
|
||||
{ type: "ui", patch: { search: "a", selIndex: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches to track search with ?", () => {
|
||||
expect(press("?")).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("plays the open album from the top on ENTER", () => {
|
||||
expect(press("Enter", { ...initialUiState, openAlbumId: "b" })).toEqual([
|
||||
{ type: "play", albumId: "b", trackIndex: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectionAt", () => {
|
||||
it("walks songs, then categories, then albums in one flat index space", () => {
|
||||
const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" };
|
||||
const found = resultsFor(ui);
|
||||
expect(found.songs.length).toBeGreaterThan(0);
|
||||
expect(selectionAt(found, 0)).toEqual({
|
||||
type: "play",
|
||||
albumId: found.songs[0]!.album.id,
|
||||
trackIndex: found.songs[0]!.index,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a category rather than playing it", () => {
|
||||
const found = resultsFor(initialUiState);
|
||||
expect(selectionAt(found, 0)).toEqual({
|
||||
type: "ui",
|
||||
patch: { category: found.categories[0]!.key, selIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns nothing when there is nothing to select", () => {
|
||||
const ui: UiState = { ...initialUiState, search: "zzzznothing" };
|
||||
expect(selectionAt(resultsFor(ui), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
77
web/src/lib/covers.ts
Normal file
77
web/src/lib/covers.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/** How an album is painted when there is no artwork, and how a book is made to look
|
||||
* like a book.
|
||||
*
|
||||
* The mockup generated everything from a single per-album `hue`. The backend now sends
|
||||
* three colours pulled out of the real cover art, so the generated fallback and the
|
||||
* artwork agree - and so do the LED strips, which run the same primary colour.
|
||||
*/
|
||||
|
||||
import type { Album, AlbumKind } from "../api/types";
|
||||
|
||||
export const isBook = (album: Album): boolean => album.kind === "book";
|
||||
|
||||
/** Shape is how you tell the two apart without reading anything: albums are square,
|
||||
* audiobooks are taller than wide, everywhere they appear - grid, list, group preview,
|
||||
* player bar, now-playing. Nothing else may set an aspect ratio on a cover. */
|
||||
export const ALBUM_ASPECT = 1;
|
||||
export const BOOK_ASPECT = 0.82;
|
||||
|
||||
export const aspectOfKind = (kind: AlbumKind): number =>
|
||||
kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT;
|
||||
|
||||
export const aspectOf = (album: Album): number => aspectOfKind(album.kind);
|
||||
|
||||
const colours = (album: Album): [string, string, string] => [
|
||||
album.colors[0] ?? "#4a6fa5",
|
||||
album.colors[1] ?? "#6a8fc5",
|
||||
album.colors[2] ?? "#a5804a",
|
||||
];
|
||||
|
||||
/** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */
|
||||
export function stripes(album: Album, width: number): string {
|
||||
const [primary, secondary] = colours(album);
|
||||
return (
|
||||
`repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` +
|
||||
`${secondary} ${width}px, ${secondary} ${width * 2}px)`
|
||||
);
|
||||
}
|
||||
|
||||
/** A book spine: page edges on the right, a darker board on the left. */
|
||||
export function spine(album: Album): string {
|
||||
const [primary, secondary] = colours(album);
|
||||
return [
|
||||
"linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%," +
|
||||
" oklch(90% 0.03 88) 97.5% 100%)",
|
||||
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
|
||||
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
|
||||
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
|
||||
`linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`,
|
||||
].join(",");
|
||||
}
|
||||
|
||||
export function coverBackground(album: Album, stripeWidth: number): string {
|
||||
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
|
||||
}
|
||||
|
||||
/** The card's own tint: warm paper for books, cool glass for music. */
|
||||
export const cardBackground = (album: Album): string =>
|
||||
isBook(album) ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)";
|
||||
|
||||
export const cardShadow = (album: Album): string =>
|
||||
isBook(album)
|
||||
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7)," +
|
||||
" 0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||
: "0 6px 18px oklch(15% 0.05 210 / .35)";
|
||||
|
||||
export const unitLabel = (album: Album, count: number): string =>
|
||||
isBook(album)
|
||||
? `${count} ${count === 1 ? "Kapitel" : "Kapitel"}`
|
||||
: `${count} ${count === 1 ? "Song" : "Songs"}`;
|
||||
|
||||
/** "Album · Artist", unless a podcast makes those the same string. */
|
||||
export const albumLine = (album: Album): string => {
|
||||
const prefix = isBook(album) ? "📖 " : "";
|
||||
return album.artist && album.artist !== album.title
|
||||
? `${prefix}${album.title} · ${album.artist}`
|
||||
: `${prefix}${album.title}`;
|
||||
};
|
||||
25
web/src/lib/format.ts
Normal file
25
web/src/lib/format.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** Time formatting, ported from the design mockup. */
|
||||
|
||||
/** Seconds as `m:ss`, or `h:mm:ss` once an audiobook runs past the hour. */
|
||||
export function clock(totalSeconds: number): string {
|
||||
const total = Math.max(0, Math.round(totalSeconds));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return hours ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
/** How much of the album is left after the current position. */
|
||||
export function remainingInAlbum(
|
||||
trackDurations: number[],
|
||||
trackIndex: number,
|
||||
position: number,
|
||||
): number {
|
||||
const current = trackDurations[trackIndex] ?? 0;
|
||||
let rest = Math.max(0, current - position);
|
||||
for (let i = trackIndex + 1; i < trackDurations.length; i++) {
|
||||
rest += trackDurations[i] ?? 0;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
187
web/src/lib/keyboard.ts
Normal file
187
web/src/lib/keyboard.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/** The keyboard state machine, ported from the design mockup.
|
||||
|
||||
Kept pure - it takes a key and the current view state and returns what should happen -
|
||||
so the whole interaction model can be tested without a DOM, and so the component that
|
||||
mounts it stays a thin adapter.
|
||||
|
||||
The mockup's key map is followed exactly, with one addition: SHIFT+arrows seek. Plain
|
||||
arrows were already taken (selection while browsing, transport and volume while
|
||||
playing), and seeking is the one thing a real player can do that the mockup could not.
|
||||
*/
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
import type { Filter, Mode, Results } from "./search";
|
||||
|
||||
export interface UiState {
|
||||
search: string;
|
||||
mode: Mode;
|
||||
filter: Filter;
|
||||
category: string | null;
|
||||
selIndex: number;
|
||||
view: "browse" | "play";
|
||||
openAlbumId: string | null;
|
||||
showHelp: boolean;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export const initialUiState: UiState = {
|
||||
search: "",
|
||||
mode: "albums",
|
||||
filter: "all",
|
||||
category: null,
|
||||
selIndex: 0,
|
||||
view: "browse",
|
||||
openAlbumId: null,
|
||||
showHelp: false,
|
||||
cols: 4,
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| { type: "ui"; patch: Partial<UiState> }
|
||||
| { type: "play"; albumId: string; trackIndex: number }
|
||||
| { type: "toggle" }
|
||||
| { type: "next" }
|
||||
| { type: "previous" }
|
||||
| { type: "volume"; delta: number }
|
||||
| { type: "seek"; delta: number }
|
||||
| { type: "pop"; freq: number };
|
||||
|
||||
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
||||
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
||||
|
||||
const FILTER_ORDER: Filter[] = ["all", "music", "book"];
|
||||
|
||||
export const VOLUME_STEP = 10;
|
||||
export const SEEK_STEP = 15;
|
||||
|
||||
/** What the flat selection index currently points at. */
|
||||
export function selectionAt(results: Results, selIndex: number): Action | null {
|
||||
const { songs, categories, albums } = results;
|
||||
const index = Math.min(selIndex, results.total - 1);
|
||||
if (index < 0) return null;
|
||||
|
||||
if (index < songs.length) {
|
||||
const hit = songs[index];
|
||||
return hit ? { type: "play", albumId: hit.album.id, trackIndex: hit.index } : null;
|
||||
}
|
||||
const afterSongs = index - songs.length;
|
||||
if (afterSongs < categories.length) {
|
||||
const category = categories[afterSongs];
|
||||
return category ? { type: "ui", patch: { category: category.key, selIndex: 0 } } : null;
|
||||
}
|
||||
const album: Album | undefined = albums[afterSongs - categories.length];
|
||||
return album ? { type: "play", albumId: album.id, trackIndex: 0 } : null;
|
||||
}
|
||||
|
||||
function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
|
||||
if (!results.total) return [];
|
||||
const cols = Math.max(1, state.cols);
|
||||
let index = Math.min(state.selIndex, results.total - 1);
|
||||
if (dx) index += dx;
|
||||
if (dy) {
|
||||
// Song hits are a single-column list; everything below them is a grid.
|
||||
index += index < results.songs.length ? dy : dy * cols;
|
||||
}
|
||||
index = Math.max(0, Math.min(index, results.total - 1));
|
||||
return [{ type: "ui", patch: { selIndex: index } }];
|
||||
}
|
||||
|
||||
/** ESC peels one layer off at a time rather than dumping you back at the top. */
|
||||
function escape(state: UiState): Action[] {
|
||||
if (state.showHelp || state.openAlbumId !== null) {
|
||||
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
||||
}
|
||||
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
||||
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
||||
return [{ type: "ui", patch: { category: null, selIndex: 0 } }];
|
||||
}
|
||||
|
||||
export interface KeyEvent {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one keypress. Returns the actions to run, or nothing when the key means
|
||||
* nothing here - the caller only calls `preventDefault()` when something came back.
|
||||
*/
|
||||
export function handleKey(event: KeyEvent, state: UiState, results: Results): Action[] {
|
||||
const { key } = event;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
switch (key.toLowerCase()) {
|
||||
case "l":
|
||||
return [{ type: "next" }];
|
||||
case "h":
|
||||
return [{ type: "previous" }];
|
||||
case "k":
|
||||
return [{ type: "volume", delta: VOLUME_STEP }];
|
||||
case "j":
|
||||
return [{ type: "volume", delta: -VOLUME_STEP }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
|
||||
|
||||
switch (key) {
|
||||
case "Tab": {
|
||||
const next = FILTER_ORDER[(FILTER_ORDER.indexOf(state.filter) + 1) % FILTER_ORDER.length]!;
|
||||
return [
|
||||
{ type: "pop", freq: 380 },
|
||||
{ type: "ui", patch: { filter: next, selIndex: 0, view: "browse", category: null } },
|
||||
];
|
||||
}
|
||||
case "/":
|
||||
return [{ type: "ui", patch: { view: "browse" } }];
|
||||
case " ":
|
||||
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
|
||||
|
||||
case "ArrowRight":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
||||
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
|
||||
case "ArrowLeft":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
|
||||
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
|
||||
case "ArrowDown":
|
||||
return browsing
|
||||
? moveSelection(state, results, 0, 1)
|
||||
: [{ type: "volume", delta: -VOLUME_STEP }];
|
||||
case "ArrowUp":
|
||||
return browsing
|
||||
? moveSelection(state, results, 0, -1)
|
||||
: [{ type: "volume", delta: VOLUME_STEP }];
|
||||
|
||||
case "?":
|
||||
return [
|
||||
{ type: "pop", freq: 460 },
|
||||
{
|
||||
type: "ui",
|
||||
patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false },
|
||||
},
|
||||
];
|
||||
case "F1":
|
||||
return [{ type: "ui", patch: { showHelp: !state.showHelp } }];
|
||||
case "Escape":
|
||||
return escape(state);
|
||||
case "Backspace":
|
||||
return state.search
|
||||
? [{ type: "ui", patch: { search: state.search.slice(0, -1), selIndex: 0 } }]
|
||||
: [];
|
||||
case "Enter": {
|
||||
if (state.openAlbumId !== null) {
|
||||
return [{ type: "play", albumId: state.openAlbumId, trackIndex: 0 }];
|
||||
}
|
||||
const chosen = selectionAt(results, state.selIndex);
|
||||
return chosen ? [chosen] : [];
|
||||
}
|
||||
default:
|
||||
if (SEARCHABLE.test(key)) {
|
||||
return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
22
web/src/lib/pop.ts
Normal file
22
web/src/lib/pop.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** The mockup's click feedback: a short rising blip per interaction. */
|
||||
|
||||
let context: AudioContext | null = null;
|
||||
|
||||
export function playPop(frequency: number): void {
|
||||
try {
|
||||
context ??= new AudioContext();
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(frequency, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.8, now + 0.08);
|
||||
gain.gain.setValueAtTime(0.15, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
oscillator.connect(gain).connect(context.destination);
|
||||
oscillator.start();
|
||||
oscillator.stop(now + 0.16);
|
||||
} catch {
|
||||
// No audio context before the first user gesture, and on some browsers never.
|
||||
}
|
||||
}
|
||||
114
web/src/lib/search.ts
Normal file
114
web/src/lib/search.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/** Browsing and searching the library, entirely in the browser.
|
||||
|
||||
The whole index arrives in one response, so type-to-search has no round trip and feels
|
||||
instant - which is the point of a keyboard-first UI. Ported from the design mockup.
|
||||
*/
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
|
||||
export type Filter = "all" | "music" | "book";
|
||||
export type Mode = "albums" | "tracks";
|
||||
|
||||
export interface SongHit {
|
||||
album: Album;
|
||||
index: number;
|
||||
title: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
key: string;
|
||||
albums: Album[];
|
||||
}
|
||||
|
||||
/** Diacritics and punctuation are noise when a child is hunting for letters. */
|
||||
export function normalize(value: string): string {
|
||||
return (value ?? "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.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;
|
||||
}
|
||||
|
||||
export function pool(albums: Album[], filter: Filter): Album[] {
|
||||
return albums.filter((album) => inFilter(album, filter));
|
||||
}
|
||||
|
||||
export interface BrowseQuery {
|
||||
albums: Album[];
|
||||
search: string;
|
||||
mode: Mode;
|
||||
filter: Filter;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
/** Which of the three lists the browse view is showing. */
|
||||
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
||||
if (query.mode === "tracks") return "tracks";
|
||||
if (query.search || query.category) return "albums";
|
||||
return "categories";
|
||||
}
|
||||
|
||||
export function categoryMatches(query: BrowseQuery): Category[] {
|
||||
if (listMode(query) !== "categories") return [];
|
||||
const map = new Map<string, Category>();
|
||||
for (const album of pool(query.albums, query.filter)) {
|
||||
const key = album.category;
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { key, albums: [] };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.albums.push(album);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, "de"));
|
||||
}
|
||||
|
||||
export function albumMatches(query: BrowseQuery): Album[] {
|
||||
if (query.mode === "tracks") return [];
|
||||
const needle = normalize(query.search);
|
||||
let candidates = pool(query.albums, query.filter);
|
||||
if (!needle && !query.category) return [];
|
||||
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
|
||||
if (!needle) return candidates;
|
||||
return candidates.filter((a) => normalize(a.title + a.artist).includes(needle));
|
||||
}
|
||||
|
||||
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
|
||||
export const MAX_SONG_HITS = 40;
|
||||
|
||||
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)) {
|
||||
album.tracks.forEach((track, index) => {
|
||||
if (!needle || normalize(track.title).includes(needle)) {
|
||||
hits.push({ album, index, title: track.title, duration: track.duration });
|
||||
}
|
||||
});
|
||||
if (hits.length >= MAX_SONG_HITS) break;
|
||||
}
|
||||
return hits.slice(0, MAX_SONG_HITS);
|
||||
}
|
||||
|
||||
export interface Results {
|
||||
songs: SongHit[];
|
||||
categories: Category[];
|
||||
albums: Album[];
|
||||
/** Selection indices run flat across songs, then categories, then albums. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function results(query: BrowseQuery): Results {
|
||||
const songs = songMatches(query);
|
||||
const categories = categoryMatches(query);
|
||||
const albums = albumMatches(query);
|
||||
return { songs, categories, albums, total: songs.length + categories.length + albums.length };
|
||||
}
|
||||
23
web/src/main.tsx
Normal file
23
web/src/main.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles/app.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
// Registered after load so it never competes with the first paint. It is what makes
|
||||
// iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js
|
||||
// for what it does and deliberately does not cache.
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
// A plain http:// origin that is not localhost cannot register one. The app
|
||||
// works exactly the same, it just cannot be installed.
|
||||
});
|
||||
});
|
||||
}
|
||||
188
web/src/styles/app.css
Normal file
188
web/src/styles/app.css
Normal file
@@ -0,0 +1,188 @@
|
||||
/* Ported from claude-design/Dolphin Beats.dc.html. The palette is oklch throughout;
|
||||
the tokens below are the handful of values that repeat. */
|
||||
|
||||
:root {
|
||||
--ink: oklch(30% 0.04 210);
|
||||
--paper: oklch(97% 0.01 210);
|
||||
--accent: oklch(70% 0.16 340);
|
||||
--accent-dim: oklch(78% 0.14 340);
|
||||
--sea-deep: oklch(20% 0.045 210);
|
||||
--shadow: oklch(15% 0.05 210 / 0.35);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
overscroll-behavior: none;
|
||||
/* Installed on a phone this is an app, not a document: no rubber-banding, no
|
||||
long-press callout, no accidental text selection when a small hand misses a
|
||||
button. Text inside the parent-mode inputs opts back in below. */
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@keyframes bubbleRise {
|
||||
0% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-120vh) scale(1.3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dolphinBob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(-2deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dolphinSwim {
|
||||
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
|
||||
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
|
||||
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||
45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); }
|
||||
60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||
70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); }
|
||||
74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); }
|
||||
85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); }
|
||||
95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); }
|
||||
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* dvh follows the iOS toolbar as it collapses; vh is the fallback for older
|
||||
browsers, where it would otherwise leave a strip of white at the bottom. */
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(55% 0.07 210) 0%,
|
||||
oklch(38% 0.06 210) 45%,
|
||||
var(--sea-deep) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: absolute;
|
||||
bottom: -40px;
|
||||
border-radius: 50%;
|
||||
animation-name: bubbleRise;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
padding: 9px 15px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
background: oklch(97% 0.01 210 / 0.16);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.pill[data-active="true"] {
|
||||
background: var(--paper);
|
||||
color: oklch(28% 0.04 210);
|
||||
box-shadow: 0 4px 14px var(--shadow);
|
||||
}
|
||||
|
||||
.card {
|
||||
backdrop-filter: blur(6px);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card[data-selected="true"] {
|
||||
outline: 5px solid var(--paper);
|
||||
outline-offset: 3px;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card[data-current="true"]:not([data-selected="true"]) {
|
||||
outline: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 24px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.round {
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.key-cap {
|
||||
font: 800 12px ui-monospace, Menlo, monospace;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background: var(--paper);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px oklch(10% 0.04 210 / 0.5);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: oklch(15% 0.03 210 / 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
input[type="range"] {
|
||||
font: inherit;
|
||||
}
|
||||
Reference in New Issue
Block a user