Files
musicmouse/web/src/App.tsx
Martin Bauer 0f08e1d16c Web frontend: touch layout for phones
Phones got the kiosk's keyboard-first layout squeezed down: shelves cut off
by the tab rail and a header taking a fifth of the screen. They now get
their own layout, chosen by a media query (narrow, or short landscape on a
touch screen); tablets and desktop are unchanged.

- Pick a group (Musik/Hörbücher/Podcasts), then a category, then scroll
  through a grid of smaller albums.
- A floating glass dock: mini player, Musik/Zimmer tabs (no typing tab - a
  phone has no keyboard for it) and a search button that turns into a real
  search field, raised above the on-screen keyboard.
- Album sheet and room page get compact phone variants.

Browse state stays in the shared UiState, so resizing or rotating keeps
your place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:33:26 +02:00

766 lines
26 KiB
TypeScript

/** 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 type { ReactNode } from "react";
import { Suspense, lazy, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { api } from "./api/client";
import { PERF_ENABLED, PERF } from "./lib/perfSettings";
import { PerfPanel } from "./components/PerfPanel";
import { useCoverWarmup } from "./hooks/useCoverWarmup";
import { useEvent } from "./hooks/useEvent";
import type { Album, HaConfig, RemoteMapping, RemoteSlotInput } from "./api/types";
import { AlbumModal } from "./components/AlbumModal";
import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience";
import { AmbienceDebugOverlay } from "./components/AmbienceDebugOverlay";
import { AppHeader } from "./components/AppHeader";
import { BrowseView } from "./components/BrowseView";
import { HelpOverlay } from "./components/HelpOverlay";
import { MobileBrowse } from "./components/mobile/MobileBrowse";
import { MobileDock } from "./components/mobile/MobileDock";
import { ParentPanel } from "./components/ParentPanel";
import { PlayerBar } from "./components/PlayerBar";
import { PlayView } from "./components/PlayView";
import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
import { TabRail } from "./components/TabRail";
import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary";
import { usePhoneLayout } from "./hooks/usePhoneLayout";
import { usePlayerState } from "./hooks/usePlayerState";
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
import type { Action, UiState } from "./lib/keyboard";
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote";
import type { Group, Mode, Results, SongHit } from "./lib/search";
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
// The room page and the typing game are each a tab away from the player and together a
// large share of the bundle; the kiosk parses and compiles only what it opens.
const RoomView = lazy(() => import("./components/RoomView").then((m) => ({ default: m.RoomView })));
const TippenApp = lazy(() =>
import("./components/TippenApp").then((m) => ({ default: m.TippenApp })),
);
/** How long an armed "A" waits for the digit that completes the shortcut. */
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
/** How long a "taste zugewiesen" confirmation stays on screen. */
const ASSIGN_STATUS_TIMEOUT_MS = 2500;
/** Volume when un-muting, matching the mockup. */
const UNMUTE_PERCENT = 60;
/** A podcast episode has no next/previous track to skip to - Next/Previous nudge the
* position instead, the way scrubbing past an ad break usually works. */
const PODCAST_SKIP_SECONDS = 30;
export function App() {
const [ui, setUi] = useState<UiState>(initialUiState);
const library = useLibrary();
const connection = usePlayerState(library.reload);
const [gridRef, columns] = useGridColumns();
// A phone gets its own touch layout (components/mobile/); everything else - the
// kiosk, a desktop browser, a tablet - keeps the keyboard-first one.
const phone = usePhoneLayout();
const [parentMode, setParentMode] = useState(
() => new URLSearchParams(location.search).get("parentMode") === "1",
);
const [perfOpen, setPerfOpen] = useState(
() => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1",
);
const [debugDynamicUI] = useState(
() => new URLSearchParams(location.search).get("debugDynamicUI") === "1",
);
const [debugSnapshot, setDebugSnapshot] = useState<AmbienceDebugSnapshot | null>(null);
// Exists regardless of `debugDynamicUI` (so `Ambience` always has consistent,
// harmless defaults - `manual.enabled` defaults to false) - only the overlay, and
// thus the ability to change either, is gated behind the query param.
const [tunables, setTunables] = useState<AmbienceTunables>(DEFAULT_TUNABLES);
const [manual, setManual] = useState<ManualControl>(DEFAULT_MANUAL_CONTROL);
// undefined: not yet resolved (hide the nav pill to avoid a flash). null: confirmed
// absent - the room page is a separate opt-in feature, off by default.
const [haConfig, setHaConfig] = useState<HaConfig | null | undefined>(undefined);
useEffect(() => {
void api.haConfig().then(setHaConfig);
}, []);
// The remote key -> album/show mapping. Always fetched (unlike haConfig, this
// endpoint has no "not configured" state - it's just empty on a fresh install) so
// the assign popup and the keyboard shortcut always have something to read/merge.
const [remoteMapping, setRemoteMapping] = useState<RemoteMapping | null>(null);
const [assignPopupOpen, setAssignPopupOpen] = useState(false);
const [assignStatus, setAssignStatus] = useState<string | null>(null);
useEffect(() => {
void api.remoteMapping().then(setRemoteMapping);
}, []);
const state = connection.state;
useEffect(() => {
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
}, [columns]);
// Normalized search keys, group partitions and each shelf's category list, derived
// once per library payload instead of per keystroke - see lib/search.ts. The library
// only reloads when the backend says a rescan finished, so this is genuinely rare.
const index = useMemo(() => buildSearchIndex(library.albums), [library.albums]);
// Kiosk only: fetch and decode every card thumbnail up front, in idle time.
useCoverWarmup(library.albums, PERF.coverWarmup);
// Typing updates `ui.search` at once and the search box with it; the results follow a
// deferred copy, so a render for an older query is dropped when the next letter lands
// instead of being finished first.
const deferredSearch = useDeferredValue(ui.search);
const results: Results = useMemo(
() =>
computeResults({
index,
search: deferredSearch,
mode: ui.mode,
group: ui.group,
category: ui.category,
}),
[index, deferredSearch, ui.mode, ui.group, ui.category],
);
// The bare root's three shelves, by category key only: the keyboard handler needs
// these to know how far Ctrl+hjkl can move within a row and which category ENTER
// opens. BrowseView renders the same shelves from the same precomputed lists.
const rootShelves = useMemo(
() =>
GROUP_ORDER.map((group) =>
categoryMatches({
index,
search: "",
mode: "albums",
category: null,
group,
}).map((entry) => entry.key),
),
[index],
);
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]);
const onMute = useCallback(
() => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT),
[setVolume, state],
);
/** The one seek primitive: an optimistic position patch plus the backend call.
* Shared by dragging the progress bar (an absolute target) and Shift+Arrow /
* podcast skip (a target computed from the current position). */
const seekTo = useCallback(
(target: number) => {
connection.optimistic({ position: target });
void api.seek(target);
},
[connection],
);
const skipPodcast = useCallback(
(direction: 1 | -1) => {
const target =
direction > 0
? Math.min(state?.duration ?? 0, (state?.position ?? 0) + PODCAST_SKIP_SECONDS)
: Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
seekTo(target);
},
[seekTo, state],
);
/** The one place a keyboard action, a click or a tap all end up. */
// Read-modify-write the whole mapping, like ParentPanel does for settings: the
// backend takes a full replacement, not a per-slot patch.
const saveMapping = useCallback(
async (mutate: (slots: Record<string, RemoteSlotInput>) => void) => {
const slots: Record<string, RemoteSlotInput> = Object.fromEntries(
(remoteMapping?.slots ?? []).map((slot) => [
slot.digit,
{ target_kind: slot.target_kind, target: slot.target },
]),
);
mutate(slots);
const updated = await api.saveRemoteMapping(slots);
setRemoteMapping(updated);
return updated;
},
[remoteMapping],
);
const assignCurrentTo = useCallback(
async (digit: string) => {
if (!currentAlbum) return;
const target = targetForAlbum(currentAlbum);
try {
await saveMapping((slots) => {
slots[digit] = target;
});
setAssignStatus(`Taste ${digit}: „${currentAlbum.title}"`);
} catch {
setAssignStatus("Konnte nicht zugewiesen werden.");
}
setAssignPopupOpen(false);
},
[currentAlbum, saveMapping],
);
const clearSlot = useCallback(
(digit: string) => {
void saveMapping((slots) => {
delete slots[digit];
});
},
[saveMapping],
);
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 "assign":
void assignCurrentTo(action.digit);
break;
case "next":
playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
skipPodcast(1);
} else {
connection.optimistic({ position: 0 });
void api.next();
}
break;
case "previous":
playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
skipPodcast(-1);
} else {
connection.optimistic({ position: 0 });
void api.previous();
}
break;
case "volume":
setVolume((state?.volume ?? 0) + action.delta);
break;
case "seek": {
if (state?.duration) {
const target = Math.max(
0,
Math.min(state.duration, (state?.position ?? 0) + action.delta),
);
seekTo(target);
}
break;
}
case "pop":
playPop(action.freq);
break;
case "mute":
onMute();
break;
}
}
},
[
assignCurrentTo,
connection,
currentAlbum,
onMute,
play,
seekTo,
setVolume,
skipPodcast,
state,
toggle,
],
);
// Held in a ref so the listener is installed once rather than on every state change.
const latest = useRef({ ui, results, run, openAlbumTrackCount: 0, rootShelves });
latest.current = {
ui,
results,
run,
openAlbumTrackCount: openAlbum?.tracks.length ?? 0,
rootShelves,
};
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,
current.openAlbumTrackCount,
current.rootShelves,
);
if (!actions.length) return;
event.preventDefault();
current.run(actions);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// An abandoned "A" press disarms itself rather than leaving the app half-primed.
useEffect(() => {
if (!ui.assignPending) return;
const timer = setTimeout(
() => setUi((previous) => ({ ...previous, assignPending: false })),
ASSIGN_PENDING_TIMEOUT_MS,
);
return () => clearTimeout(timer);
}, [ui.assignPending]);
useEffect(() => {
if (!assignStatus) return;
const timer = setTimeout(() => setAssignStatus(null), ASSIGN_STATUS_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [assignStatus]);
const onEnterGroup = useEvent((group: Group, category: string | null) => {
playPop(category ? 440 : 380);
setUi((previous) => ({ ...previous, group, category, search: "", searchOpen: false, selIndex: 0 }));
});
const onCategory = useEvent((key: string | null) => {
if (key) playPop(440);
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
});
const onOpenAlbum = useEvent((album: Album, navIndex: number) => {
// A podcast episode is a single track behaving like an audiobook of one chapter -
// there's nothing a metadata popup would add, so it just starts playing.
if (groupOf(album) === "podcasts") {
setUi((previous) => ({ ...previous, selIndex: navIndex }));
play(album.id, 0);
return;
}
playPop(420);
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex, modalTrackIndex: 0 }));
});
/** Clicking the title text (as opposed to the cover) starts the album right away -
* the same thing ENTER does from the keyboard - instead of opening the track list. */
const onPlayAlbum = useEvent((album: Album, navIndex: number) => {
setUi((previous) => ({ ...previous, selIndex: navIndex }));
play(album.id, 0);
});
const onOpenCurrentAlbum = () => {
if (!currentAlbum) return;
playPop(420);
// currentAlbum is by definition whatever's playing, so its own track_index is the
// track already highlighted - the modal opens showing where you actually are.
setUi((previous) => ({
...previous,
openAlbumId: currentAlbum.id,
modalTrackIndex: state?.track_index ?? 0,
}));
};
const onPlaySong = useEvent((hit: SongHit) => play(hit.album.id, hit.index));
const onMode = useEvent((mode: Mode) => setUi((previous) => ({ ...previous, mode, selIndex: 0 })));
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
// The single top-left "back" button, shared by every level of the music page -
// search, categories, play view - so it always means "one step out" no matter what
// is on screen. Nothing to back out of on the room page (it has no sub-levels), and
// the toggle button is what moves between the two pages.
// The phone layout has its own back button in the browse screen's nav bar.
const canGoBack =
ui.page === "music" && (ui.view === "play" || (!phone && !isRootShelf(ui)));
const onBack = () => {
if (ui.view === "play") {
setUi((previous) => ({ ...previous, view: "browse" }));
return;
}
run(browseBackActions(ui));
};
const onSelectPage = (page: UiState["page"]) =>
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
// Tabs, unlike the rail's toggles: tapping the open one again leaves it open.
const onSelectPhonePage = (page: UiState["page"]) =>
setUi((previous) => ({ ...previous, page, search: "", searchOpen: false }));
return (
<div
className="stage"
data-blur={SHOW_GLASS_BLUR ? undefined : "off"}
data-cardblur={SHOW_CARD_BLUR ? undefined : "off"}
>
{state && SHOW_AMBIENCE && (
<Ambience
album={currentAlbum}
state={state}
tunables={tunables}
manual={manual}
onDebugFrame={debugDynamicUI ? setDebugSnapshot : undefined}
/>
)}
{phone && ui.page === "music" && ui.view === "browse" && state && (
<div className="m-page">
<MobileBrowse
index={index}
group={ui.group}
category={ui.category}
searching={ui.searchOpen}
search={deferredSearch}
typed={ui.search}
mode={ui.mode}
currentAlbumId={state.album_id}
statusLabel={connectionLabel(connection.online, state)}
offline={!connection.online}
onEnterGroup={onEnterGroup}
onMode={onMode}
onOpenAlbum={onOpenAlbum}
onPlaySong={onPlaySong}
/>
</div>
)}
{!phone && ui.page === "music" && 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}
group={ui.group}
index={index}
mode={ui.mode}
search={deferredSearch}
typed={ui.search}
searchOpen={ui.searchOpen}
onToggleMode={() => run(toggleModeActions(ui))}
columns={columns}
category={ui.category}
selIndex={ui.selIndex}
shelfRow={ui.shelfRow}
currentAlbumId={state.album_id}
gridRef={gridRef}
onEnterGroup={onEnterGroup}
onCategory={onCategory}
onOpenAlbum={onOpenAlbum}
onPlayAlbum={onPlayAlbum}
onPlaySong={onPlaySong}
/>
</div>
)}
{ui.page === "music" && ui.view === "play" && state && (
<PlayView
state={state}
album={currentAlbum}
onToggle={toggle}
onNext={onNext}
onPrevious={onPrevious}
onSeek={seekTo}
onVolume={setVolume}
onMute={onMute}
onOpenAlbum={onOpenCurrentAlbum}
onOpenAssign={() => setAssignPopupOpen(true)}
assignPending={ui.assignPending}
/>
)}
{assignPopupOpen && currentAlbum && (
<RemoteAssignPopup
album={currentAlbum}
mapping={remoteMapping}
albums={library.albums}
onAssign={(digit) => void assignCurrentTo(digit)}
onClear={clearSlot}
onClose={() => setAssignPopupOpen(false)}
/>
)}
{assignStatus && (
<div
style={{
position: "absolute",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
zIndex: 7,
background: "var(--ink)",
color: "#fff",
fontSize: 14,
fontWeight: 800,
padding: "10px 20px",
borderRadius: 999,
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
}}
>
{assignStatus}
</div>
)}
<Suspense fallback={null}>
{ui.page === "room" && haConfig && <RoomView config={haConfig} phone={phone} />}
{ui.page === "typing" && (
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
)}
</Suspense>
{openAlbum && state && (
<AlbumModal
album={openAlbum}
currentAlbumId={state.album_id}
currentTrackIndex={state.track_index}
selectedIndex={ui.modalTrackIndex}
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
phone={phone}
/>
)}
{canGoBack && (
<CornerButton side="left" onClick={onBack} label="Zurück">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M12.5 4.5 6.5 10l6 5.5"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</CornerButton>
)}
{!phone && <TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />}
{phone && (ui.page === "room" || (ui.page === "music" && ui.view === "browse")) && (
<MobileDock
page={ui.page}
onSelectPage={onSelectPhonePage}
showRoom={haConfig != null}
searching={ui.page === "music" && ui.searchOpen}
typed={ui.search}
onOpenSearch={() => setUi((previous) => ({ ...previous, searchOpen: true }))}
onCloseSearch={() =>
setUi((previous) => ({ ...previous, search: "", searchOpen: false, mode: "albums" }))
}
onSearchChange={(text) => setUi((previous) => ({ ...previous, search: text, selIndex: 0 }))}
state={state}
album={currentAlbum}
onToggle={toggle}
onNext={onNext}
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
/>
)}
{ui.showHelp && (
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
)}
{!phone && ui.page === "music" && ui.view === "browse" && state && currentAlbum && (
<PlayerBar
state={state}
album={currentAlbum}
onToggle={toggle}
onNext={onNext}
onPrevious={onPrevious}
onSeek={seekTo}
onVolume={setVolume}
onMute={onMute}
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
/>
)}
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
{PERF_ENABLED && !perfOpen && (
<button
onClick={() => setPerfOpen(true)}
aria-label="Leistungseinstellungen"
style={{
position: "absolute",
left: 8,
bottom: 8,
zIndex: 5,
width: 32,
height: 32,
border: "none",
borderRadius: 999,
cursor: "pointer",
background: "oklch(15% 0.03 210 / .35)",
color: "#fff",
opacity: 0.5,
}}
>
⚡
</button>
)}
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
{(!state || library.loading) && <Splash error={library.error} />}
{debugDynamicUI && debugSnapshot && (
<AmbienceDebugOverlay
snapshot={debugSnapshot}
tunables={tunables}
onTunablesChange={setTunables}
manual={manual}
onManualChange={setManual}
playing={state?.playing ?? false}
/>
)}
</div>
);
}
/** The one fixed round button in each top corner: back (left) and the music/room
* toggle (right). Same position and shape everywhere they appear, so "top left" and
* "top right" always mean the same thing. */
function CornerButton({
side,
onClick,
label,
children,
}: {
side: "left" | "right";
onClick: () => void;
label: string;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
aria-label={label}
title={label}
style={{
position: "absolute",
top: 18,
[side]: 24,
zIndex: 3,
width: 44,
height: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
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",
}}
>
{children}
</button>
);
}
function connectionLabel(online: boolean, state: { active_figure: string | null }): string | null {
return !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null;
}
function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) {
const label = connectionLabel(online, state);
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>
);
}