Implements the room-control page from the design mockup: a scenes row above cards for shutters, color lamps, and brightness-only lamps, all driven by a new `general.ha` config section (server URL, token, ordered device/scene lists). The backend proxies every Home Assistant call server-side (GET/POST /api/ha/...) rather than the browser calling Home Assistant directly, so the long-lived token never leaves the LAN device and Home Assistant's own CORS settings don't need to know about musicmouse at all. Card kind (shutter/color/brightness-only) is inferred at runtime from what Home Assistant reports about each entity, not configured explicitly. Also stops tracking python-backend/config.yml, which had drifted into the repo despite its own header saying it shouldn't be - it now carries real credentials locally and needs to stay untracked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
389 lines
13 KiB
TypeScript
389 lines
13 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { api } from "./api/client";
|
|
import type { Album, HaConfig } 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 { RoomView } from "./components/RoomView";
|
|
import { useGridColumns } from "./hooks/useGridColumns";
|
|
import { useLibrary } from "./hooks/useLibrary";
|
|
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 { Group, Results, SongHit } from "./lib/search";
|
|
import { groupOf, results as computeResults } from "./lib/search";
|
|
|
|
/** 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();
|
|
const [parentMode, setParentMode] = useState(
|
|
() => new URLSearchParams(location.search).get("parentMode") === "1",
|
|
);
|
|
// 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);
|
|
}, []);
|
|
|
|
const state = connection.state;
|
|
|
|
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,
|
|
group: ui.group,
|
|
category: ui.category,
|
|
}),
|
|
[library.albums, ui.search, ui.mode, ui.group, 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);
|
|
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
|
|
const target = Math.min(
|
|
state?.duration ?? 0,
|
|
(state?.position ?? 0) + PODCAST_SKIP_SECONDS,
|
|
);
|
|
connection.optimistic({ position: target });
|
|
void api.seek(target);
|
|
} else {
|
|
connection.optimistic({ position: 0 });
|
|
void api.next();
|
|
}
|
|
break;
|
|
case "previous":
|
|
playPop(260);
|
|
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
|
|
const target = Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
|
|
connection.optimistic({ position: target });
|
|
void api.seek(target);
|
|
} 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),
|
|
);
|
|
connection.optimistic({ position: target });
|
|
void api.seek(target);
|
|
}
|
|
break;
|
|
}
|
|
case "pop":
|
|
playPop(action.freq);
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
[connection, currentAlbum, play, 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 onEnterGroup = (group: Group, category: string | null) => {
|
|
playPop(category ? 440 : 380);
|
|
setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 }));
|
|
};
|
|
|
|
const onBackToRoot = () => {
|
|
playPop(380);
|
|
setUi((previous) => ({ ...previous, group: null, category: null, selIndex: 0 }));
|
|
};
|
|
|
|
const onCategory = (key: string | null) => {
|
|
if (key) playPop(440);
|
|
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
|
|
};
|
|
|
|
const onOpenAlbum = (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 }));
|
|
};
|
|
|
|
const onOpenCurrentAlbum = () => {
|
|
if (!currentAlbum) return;
|
|
playPop(420);
|
|
setUi((previous) => ({ ...previous, openAlbumId: currentAlbum.id }));
|
|
};
|
|
|
|
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
|
|
const onSeek = (target: number) => {
|
|
connection.optimistic({ position: target });
|
|
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} />}
|
|
link={
|
|
haConfig
|
|
? {
|
|
label: "💡 Mein Zimmer",
|
|
onClick: () => setUi((previous) => ({ ...previous, view: "room" })),
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
<BrowseView
|
|
results={results}
|
|
group={ui.group}
|
|
albums={library.albums}
|
|
mode={ui.mode}
|
|
search={ui.search}
|
|
category={ui.category}
|
|
selIndex={ui.selIndex}
|
|
currentAlbumId={state.album_id}
|
|
gridRef={gridRef}
|
|
onEnterGroup={onEnterGroup}
|
|
onBackToRoot={onBackToRoot}
|
|
onCategory={onCategory}
|
|
onOpenAlbum={onOpenAlbum}
|
|
onPlaySong={onPlaySong}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{ui.view === "play" && state && (
|
|
<PlayView
|
|
state={state}
|
|
album={currentAlbum}
|
|
onToggle={toggle}
|
|
onNext={() => run([{ type: "next" }])}
|
|
onPrevious={() => run([{ type: "previous" }])}
|
|
onSeek={onSeek}
|
|
onVolume={setVolume}
|
|
onMute={onMute}
|
|
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
|
|
onOpenAlbum={onOpenCurrentAlbum}
|
|
/>
|
|
)}
|
|
|
|
{ui.view === "room" && haConfig && (
|
|
<RoomView
|
|
config={haConfig}
|
|
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}
|
|
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>
|
|
);
|
|
}
|