On an iPhone the sticky group switch sat on a fixed teal fade, which was the wrong colour as soon as the ambience took on another album's tint (brown for Conni). It is now a neutral frosted blur that fades out, so it suits whatever is playing, and it reaches up under the status bar so scrolled content never shows behind the clock. After the search keyboard had been up, 100dvh in the home-screen app came back a status bar short and the white page showed as a strip along the bottom. On phones the stage is pinned to the viewport edges instead, with the sea colour behind it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
792 lines
27 KiB
TypeScript
792 lines
27 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-phone={phone || undefined}
|
|
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)} />}
|
|
|
|
{/* The F1 sheet, reachable without knowing about F1. Not on a phone (no keyboard)
|
|
or the typing page (TippenApp has its own help and owns every key there). */}
|
|
{!phone && ui.page !== "typing" && !ui.showHelp && (
|
|
<button
|
|
onClick={() => setUi((previous) => ({ ...previous, showHelp: true }))}
|
|
aria-label="Tastenkürzel"
|
|
style={{
|
|
position: "absolute",
|
|
left: PERF_ENABLED && !perfOpen ? 48 : 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>
|
|
)}
|
|
|
|
{(!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>
|
|
);
|
|
}
|