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>
766 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|