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>
This commit is contained in:
2026-08-27 23:45:29 +02:00
parent edb6e5e027
commit a8ed350aec
27 changed files with 1437 additions and 163 deletions

View File

@@ -9,7 +9,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "./api/client";
import type { Album } from "./api/types";
import type { Album, HaConfig } from "./api/types";
import { AlbumModal } from "./components/AlbumModal";
import { AppHeader } from "./components/AppHeader";
import { BrowseView } from "./components/BrowseView";
@@ -18,19 +18,23 @@ 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 { usePlaybackClock } from "./hooks/usePlaybackClock";
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 { Filter, Results, SongHit } from "./lib/search";
import { results as computeResults } from "./lib/search";
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();
@@ -39,9 +43,15 @@ export function App() {
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;
const position = usePlaybackClock(state?.position ?? 0, state?.playing ?? false);
useEffect(() => {
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
@@ -53,10 +63,10 @@ export function App() {
albums: library.albums,
search: ui.search,
mode: ui.mode,
filter: ui.filter,
group: ui.group,
category: ui.category,
}),
[library.albums, ui.search, ui.mode, ui.filter, ui.category],
[library.albums, ui.search, ui.mode, ui.group, ui.category],
);
const byId = useMemo(
@@ -110,27 +120,50 @@ export function App() {
break;
case "next":
playPop(260);
void api.next();
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);
void api.previous();
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":
case "seek": {
if (state?.duration) {
void api.seek(Math.max(0, Math.min(state.duration, position + action.delta)));
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;
}
}
},
[play, position, setVolume, state, toggle],
[connection, currentAlbum, play, setVolume, state, toggle],
);
// Held in a ref so the listener is installed once rather than on every state change.
@@ -153,15 +186,14 @@ export function App() {
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const onFilter = (filter: Filter) => {
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,
filter,
selIndex: 0,
view: "browse",
category: null,
}));
setUi((previous) => ({ ...previous, group: null, category: null, selIndex: 0 }));
};
const onCategory = (key: string | null) => {
@@ -170,12 +202,28 @@ export function App() {
};
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) => void api.seek(target);
const onSeek = (target: number) => {
connection.optimistic({ position: target });
void api.seek(target);
};
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
return (
@@ -192,17 +240,29 @@ export function App() {
flexDirection: "column",
}}
>
<AppHeader status={<ConnectionDot online={connection.online} state={state} />} />
<AppHeader
status={<ConnectionDot online={connection.online} state={state} />}
link={
haConfig
? {
label: "💡 Mein Zimmer",
onClick: () => setUi((previous) => ({ ...previous, view: "room" })),
}
: undefined
}
/>
<BrowseView
results={results}
filter={ui.filter}
group={ui.group}
albums={library.albums}
mode={ui.mode}
search={ui.search}
category={ui.category}
selIndex={ui.selIndex}
currentAlbumId={state.album_id}
gridRef={gridRef}
onFilter={onFilter}
onEnterGroup={onEnterGroup}
onBackToRoot={onBackToRoot}
onCategory={onCategory}
onOpenAlbum={onOpenAlbum}
onPlaySong={onPlaySong}
@@ -214,7 +274,6 @@ export function App() {
<PlayView
state={state}
album={currentAlbum}
position={position}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}
@@ -222,6 +281,14 @@ export function App() {
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" }))}
/>
)}
@@ -266,7 +333,6 @@ export function App() {
<PlayerBar
state={state}
album={currentAlbum}
position={position}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}