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:
@@ -10,15 +10,16 @@ playing), and seeking is the one thing a real player can do that the mockup coul
|
||||
*/
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
import type { Filter, Mode, Results } from "./search";
|
||||
import type { Group, Mode, Results } from "./search";
|
||||
|
||||
export interface UiState {
|
||||
search: string;
|
||||
mode: Mode;
|
||||
filter: Filter;
|
||||
/** `null` is the bare root screen (three shelves); otherwise which one is open. */
|
||||
group: Group | null;
|
||||
category: string | null;
|
||||
selIndex: number;
|
||||
view: "browse" | "play";
|
||||
view: "browse" | "play" | "room";
|
||||
openAlbumId: string | null;
|
||||
showHelp: boolean;
|
||||
cols: number;
|
||||
@@ -27,7 +28,7 @@ export interface UiState {
|
||||
export const initialUiState: UiState = {
|
||||
search: "",
|
||||
mode: "albums",
|
||||
filter: "all",
|
||||
group: null,
|
||||
category: null,
|
||||
selIndex: 0,
|
||||
view: "browse",
|
||||
@@ -49,7 +50,7 @@ export type Action =
|
||||
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
||||
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
||||
|
||||
const FILTER_ORDER: Filter[] = ["all", "music", "book"];
|
||||
const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
|
||||
|
||||
export const VOLUME_STEP = 10;
|
||||
export const SEEK_STEP = 15;
|
||||
@@ -91,9 +92,18 @@ function escape(state: UiState): Action[] {
|
||||
if (state.showHelp || state.openAlbumId !== null) {
|
||||
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
||||
}
|
||||
if (state.view === "play" || state.view === "room") {
|
||||
return [{ type: "ui", patch: { view: "browse" } }];
|
||||
}
|
||||
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
||||
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
||||
return [{ type: "ui", patch: { category: null, selIndex: 0 } }];
|
||||
if (state.category !== null) return [{ type: "ui", patch: { category: null, selIndex: 0 } }];
|
||||
return [{ type: "ui", patch: { group: null, selIndex: 0 } }];
|
||||
}
|
||||
|
||||
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */
|
||||
function isRootShelf(state: UiState): boolean {
|
||||
return state.group === null && !state.search && state.mode === "albums" && state.category === null;
|
||||
}
|
||||
|
||||
export interface KeyEvent {
|
||||
@@ -129,10 +139,11 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
|
||||
|
||||
switch (key) {
|
||||
case "Tab": {
|
||||
const next = FILTER_ORDER[(FILTER_ORDER.indexOf(state.filter) + 1) % FILTER_ORDER.length]!;
|
||||
const currentIndex = state.group ? GROUP_ORDER.indexOf(state.group) : -1;
|
||||
const next = GROUP_ORDER[(currentIndex + 1) % GROUP_ORDER.length]!;
|
||||
return [
|
||||
{ type: "pop", freq: 380 },
|
||||
{ type: "ui", patch: { filter: next, selIndex: 0, view: "browse", category: null } },
|
||||
{ type: "ui", patch: { group: next, selIndex: 0, view: "browse", category: null } },
|
||||
];
|
||||
}
|
||||
case "/":
|
||||
@@ -142,15 +153,27 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
|
||||
|
||||
case "ArrowRight":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
||||
if (browsing && isRootShelf(state)) {
|
||||
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||
}
|
||||
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
|
||||
case "ArrowLeft":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
|
||||
if (browsing && isRootShelf(state)) {
|
||||
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||
}
|
||||
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
|
||||
case "ArrowDown":
|
||||
if (browsing && isRootShelf(state)) {
|
||||
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||
}
|
||||
return browsing
|
||||
? moveSelection(state, results, 0, 1)
|
||||
: [{ type: "volume", delta: -VOLUME_STEP }];
|
||||
case "ArrowUp":
|
||||
if (browsing && isRootShelf(state)) {
|
||||
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||
}
|
||||
return browsing
|
||||
? moveSelection(state, results, 0, -1)
|
||||
: [{ type: "volume", delta: VOLUME_STEP }];
|
||||
|
||||
Reference in New Issue
Block a user