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:
118
web/src/App.tsx
118
web/src/App.tsx
@@ -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" }])}
|
||||
|
||||
Reference in New Issue
Block a user