Files
musicmouse/web/src/App.tsx
Martin Bauer a8ed350aec Add "Mein Zimmer" room-control page (Home Assistant, proxied through the backend)
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>
2026-08-27 23:45:29 +02:00

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>
);
}