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:
1
python-backend/.gitignore
vendored
Normal file
1
python-backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
config.yml
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# Example config for the MusicMouse backend.
|
|
||||||
#
|
|
||||||
# python -m musicmouse --config /media/musicmouse/config.yml
|
|
||||||
#
|
|
||||||
# Unknown keys are rejected rather than ignored, and every problem in the file is
|
|
||||||
# reported at once, so a typo fails at startup with the path to the offending line.
|
|
||||||
# Keep the real config (with credentials) off the repo - on the device only.
|
|
||||||
|
|
||||||
general:
|
|
||||||
# The music collection. One path; the shelves underneath it are fixed names, not
|
|
||||||
# settings, because each one has its own quirks the code already knows about:
|
|
||||||
#
|
|
||||||
# <root>/Figuren/<figure name>/ one folder per figurine
|
|
||||||
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
|
||||||
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
|
||||||
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
|
||||||
#
|
|
||||||
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
|
|
||||||
# of the files' tags. Relative paths resolve against this file's directory.
|
|
||||||
library:
|
|
||||||
root: /home/martin/Music
|
|
||||||
# Scan results, extracted cover art and track analysis. Safe to delete: the index
|
|
||||||
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
|
||||||
# is expensive to recompute.
|
|
||||||
cache: .musicmouse-cache
|
|
||||||
|
|
||||||
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
|
||||||
# Required - use "simulate" to run without the mouse attached, which is a complete
|
|
||||||
# setup on its own because the web front-end can drive the player by itself. RFID,
|
|
||||||
# buttons and LEDs then do nothing, and startup says so every boot.
|
|
||||||
serial_port: "simulate"
|
|
||||||
baudrate: 115200
|
|
||||||
reconnect_interval: 5.0
|
|
||||||
|
|
||||||
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
|
|
||||||
# default output. Required - use "simulate" for a player that makes no sound, which
|
|
||||||
# is handy when working on the web UI on a machine whose audio you would rather not
|
|
||||||
# commandeer. Startup says so every boot.
|
|
||||||
#
|
|
||||||
# Both of these are required rather than optional on purpose: running blind or silent
|
|
||||||
# has to be asked for, so a config that lost a line fails loudly instead of booting
|
|
||||||
# into something that looks like it is working.
|
|
||||||
alsa_device: "default"
|
|
||||||
|
|
||||||
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
|
||||||
min_volume: 0
|
|
||||||
max_volume: 80
|
|
||||||
initial_volume: 40
|
|
||||||
volume_increment: 5 # per rotary-encoder click
|
|
||||||
|
|
||||||
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
|
||||||
button_leds_brightness: 0.5
|
|
||||||
|
|
||||||
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
|
||||||
# a half-finished .tmp - is ignored.
|
|
||||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
|
||||||
|
|
||||||
# The web front-end. Omit the whole section to run without it.
|
|
||||||
#
|
|
||||||
# There is no authentication: this is a device on a home network. The settings panel
|
|
||||||
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
|
|
||||||
# to this file. Put it behind a reverse proxy if that is not good enough.
|
|
||||||
web:
|
|
||||||
host: "0.0.0.0"
|
|
||||||
port: 8080
|
|
||||||
# Built frontend to serve at /. Omit to expose only the JSON API.
|
|
||||||
static_dir: ../web/dist
|
|
||||||
|
|
||||||
# Home Assistant integration. Omit the whole section to run without MQTT.
|
|
||||||
# The backend exposes three lights, a player sensor, a volume slider, transport
|
|
||||||
# buttons, device triggers for every button/touch area, and a tag scanner.
|
|
||||||
#mqtt:
|
|
||||||
# server: "homeassistant.local"
|
|
||||||
# port: 1883
|
|
||||||
# user: "musicmouse"
|
|
||||||
# password: "REPLACE_WITH_MQTT_PASSWORD"
|
|
||||||
# base_topic: "musicmouse"
|
|
||||||
# discovery_prefix: "homeassistant"
|
|
||||||
# device_id: "musicmouse"
|
|
||||||
# device_name: "Music Mouse"
|
|
||||||
# reconnect_interval: 10.0
|
|
||||||
|
|
||||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
|
||||||
figures:
|
|
||||||
fuchs:
|
|
||||||
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
|
||||||
id: "04a1b2c3d4"
|
|
||||||
# Exactly four colours: primary, secondary, background, accent.
|
|
||||||
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
|
||||||
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
|
||||||
|
|
||||||
eule:
|
|
||||||
id: "04b2c3d4e5"
|
|
||||||
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
|
||||||
@@ -80,6 +80,32 @@ general:
|
|||||||
device_name: "Music Mouse"
|
device_name: "Music Mouse"
|
||||||
reconnect_interval: 10.0
|
reconnect_interval: 10.0
|
||||||
|
|
||||||
|
# Room control page ("Mein Zimmer"). Omit the whole section to hide the page. This is
|
||||||
|
# the opposite direction from mqtt above: it's musicmouse controlling Home Assistant
|
||||||
|
# entities, not the other way round. The backend proxies every call to Home
|
||||||
|
# Assistant's REST API with this token attached; the browser never sees it, only
|
||||||
|
# entity ids and display names. Home Assistant's own CORS settings do not need to
|
||||||
|
# allow musicmouse's origin for this - the browser only ever talks to musicmouse.
|
||||||
|
ha:
|
||||||
|
url: "http://homeassistant.local:8123"
|
||||||
|
# A long-lived access token, created under the HA user's own profile page.
|
||||||
|
token: "REPLACE_WITH_HA_LONG_LIVED_TOKEN"
|
||||||
|
# Cards on the room page, in this order. "name" is optional; falls back to the
|
||||||
|
# entity id if omitted.
|
||||||
|
devices:
|
||||||
|
- entity_id: cover.kinderzimmer_rollo
|
||||||
|
name: "Rollo"
|
||||||
|
- entity_id: light.kinderzimmer_hue_beyond_links
|
||||||
|
name: "Hue Beyond links"
|
||||||
|
- entity_id: light.kinderzimmer_deckenlampe
|
||||||
|
name: "Deckenlampe"
|
||||||
|
# Scene pill row above the cards, in this order.
|
||||||
|
scenes:
|
||||||
|
- entity_id: scene.kinderzimmer_lesen
|
||||||
|
name: "Lesen"
|
||||||
|
- entity_id: scene.kinderzimmer_gute_nacht
|
||||||
|
name: "Gute Nacht"
|
||||||
|
|
||||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||||
figures:
|
figures:
|
||||||
fuchs:
|
fuchs:
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ __all__ = [
|
|||||||
"FigureColors",
|
"FigureColors",
|
||||||
"FigureConfig",
|
"FigureConfig",
|
||||||
"GeneralConfig",
|
"GeneralConfig",
|
||||||
|
"HaConfig",
|
||||||
|
"HaDeviceConfig",
|
||||||
"LibraryConfig",
|
"LibraryConfig",
|
||||||
"MqttConfig",
|
"MqttConfig",
|
||||||
"WebConfig",
|
"WebConfig",
|
||||||
@@ -173,6 +175,36 @@ class WebConfig(_Strict):
|
|||||||
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
|
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
|
||||||
|
|
||||||
|
|
||||||
|
class HaDeviceConfig(_Strict):
|
||||||
|
"""One Home Assistant entity to expose to the room-control page ("Mein Zimmer")."""
|
||||||
|
|
||||||
|
entity_id: str
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HaConfig(_Strict):
|
||||||
|
"""Home Assistant integration for the room-control page ("Mein Zimmer").
|
||||||
|
|
||||||
|
The backend never calls Home Assistant itself - it only hands the browser the
|
||||||
|
server URL, the token, and these two ordered lists. Control happens directly from
|
||||||
|
the browser to Home Assistant's own REST API, so this token grants full HA control
|
||||||
|
to anything on the LAN that can reach musicmouse. See config.yml.example.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
token: str
|
||||||
|
#: Order is preserved and drives the device card grid on the room page.
|
||||||
|
devices: list[HaDeviceConfig] = Field(default_factory=list)
|
||||||
|
#: Order is preserved and drives the scene pill row on the room page.
|
||||||
|
scenes: list[HaDeviceConfig] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_something_configured(self) -> Self:
|
||||||
|
if not self.devices and not self.scenes:
|
||||||
|
raise ValueError("configure at least one device or scene, or omit the ha section")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class GeneralConfig(_Strict):
|
class GeneralConfig(_Strict):
|
||||||
library: LibraryConfig
|
library: LibraryConfig
|
||||||
|
|
||||||
@@ -188,6 +220,7 @@ class GeneralConfig(_Strict):
|
|||||||
|
|
||||||
mqtt: MqttConfig | None = None
|
mqtt: MqttConfig | None = None
|
||||||
web: WebConfig | None = None
|
web: WebConfig | None = None
|
||||||
|
ha: HaConfig | None = None
|
||||||
|
|
||||||
min_volume: int = Field(default=0, ge=0, le=200)
|
min_volume: int = Field(default=0, ge=0, le=200)
|
||||||
max_volume: int = Field(default=100, ge=0, le=200)
|
max_volume: int = Field(default=100, ge=0, le=200)
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx2
|
||||||
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
|
from musicmouse.config import HaConfig
|
||||||
from musicmouse.events import (
|
from musicmouse.events import (
|
||||||
IntentEvent,
|
IntentEvent,
|
||||||
NextTrackRequested,
|
NextTrackRequested,
|
||||||
@@ -32,6 +35,8 @@ from musicmouse.services.web.hub import StateHub
|
|||||||
from musicmouse.services.web.schemas import (
|
from musicmouse.services.web.schemas import (
|
||||||
AlbumOut,
|
AlbumOut,
|
||||||
BeatsOut,
|
BeatsOut,
|
||||||
|
HaConfigOut,
|
||||||
|
HaDeviceOut,
|
||||||
LibraryOut,
|
LibraryOut,
|
||||||
PlayerStateOut,
|
PlayerStateOut,
|
||||||
PlayIn,
|
PlayIn,
|
||||||
@@ -52,7 +57,9 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = ["build_router"]
|
__all__ = ["build_router"]
|
||||||
|
|
||||||
def build_router(app: App, hub: StateHub, config_path: Path) -> APIRouter:
|
def build_router(
|
||||||
|
app: App, hub: StateHub, config_path: Path, ha_client: httpx2.AsyncClient
|
||||||
|
) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
def emit(intent: IntentEvent) -> Response:
|
def emit(intent: IntentEvent) -> Response:
|
||||||
@@ -189,4 +196,52 @@ def build_router(app: App, hub: StateHub, config_path: Path) -> APIRouter:
|
|||||||
await hub.broadcast_state()
|
await hub.broadcast_state()
|
||||||
return read_settings(general)
|
return read_settings(general)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- room control
|
||||||
|
#
|
||||||
|
# The browser never sees the Home Assistant token: it stays server-side, attached
|
||||||
|
# to every proxied request below. The browser only gets to know entity ids and
|
||||||
|
# display names (get_ha_config) and can ask this backend to relay a states read or
|
||||||
|
# a service call - the same shape of access the token itself grants, just without
|
||||||
|
# ever leaving the LAN device. Entity ids are not restricted to the configured
|
||||||
|
# list; that would only stop someone who already has enough access to open this
|
||||||
|
# unauthenticated API from asking Home Assistant about a different entity, which
|
||||||
|
# matches the rest of this API's "trusted LAN device" threat model.
|
||||||
|
|
||||||
|
def _require_ha() -> HaConfig:
|
||||||
|
ha = app.config.general.ha
|
||||||
|
if ha is None:
|
||||||
|
raise HTTPException(status_code=404, detail="ha not configured")
|
||||||
|
return ha
|
||||||
|
|
||||||
|
async def _proxy(method: str, path: str, ha: HaConfig, **kwargs: Any) -> Response:
|
||||||
|
try:
|
||||||
|
upstream = await ha_client.request(
|
||||||
|
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
|
||||||
|
)
|
||||||
|
except httpx2.HTTPError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502, detail=f"Home Assistant unreachable: {exc}"
|
||||||
|
) from exc
|
||||||
|
return Response(
|
||||||
|
content=upstream.content,
|
||||||
|
status_code=upstream.status_code,
|
||||||
|
media_type=upstream.headers.get("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/ha")
|
||||||
|
def get_ha_config() -> HaConfigOut:
|
||||||
|
ha = _require_ha()
|
||||||
|
return HaConfigOut(
|
||||||
|
devices=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.devices],
|
||||||
|
scenes=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.scenes],
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/ha/states/{entity_id}")
|
||||||
|
async def get_ha_state(entity_id: str) -> Response:
|
||||||
|
return await _proxy("GET", f"/api/states/{entity_id}", _require_ha())
|
||||||
|
|
||||||
|
@router.post("/ha/services/{domain}/{service}")
|
||||||
|
async def call_ha_service(domain: str, service: str, body: dict[str, Any]) -> Response:
|
||||||
|
return await _proxy("POST", f"/api/services/{domain}/{service}", _require_ha(), json=body)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from musicmouse.library.analysis import TrackAnalysis
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"AlbumOut",
|
"AlbumOut",
|
||||||
"BeatsOut",
|
"BeatsOut",
|
||||||
|
"HaConfigOut",
|
||||||
|
"HaDeviceOut",
|
||||||
"LibraryOut",
|
"LibraryOut",
|
||||||
"PlayIn",
|
"PlayIn",
|
||||||
"PlayerStateOut",
|
"PlayerStateOut",
|
||||||
@@ -154,3 +156,17 @@ class SettingsIn(BaseModel):
|
|||||||
initial_volume: int = Field(ge=0, le=200)
|
initial_volume: int = Field(ge=0, le=200)
|
||||||
volume_increment: int = Field(ge=1, le=100)
|
volume_increment: int = Field(ge=1, le=100)
|
||||||
button_leds_brightness: float = Field(ge=0, le=1)
|
button_leds_brightness: float = Field(ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class HaDeviceOut(BaseModel):
|
||||||
|
entity_id: str
|
||||||
|
name: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class HaConfigOut(BaseModel):
|
||||||
|
"""No ``url``/``token`` here on purpose - the browser talks to this backend, which
|
||||||
|
proxies to Home Assistant with the token attached server-side. See
|
||||||
|
``services/web/api.py``'s room-control section."""
|
||||||
|
|
||||||
|
devices: list[HaDeviceOut]
|
||||||
|
scenes: list[HaDeviceOut]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import contextlib
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx2
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -27,11 +28,23 @@ _log = logging.getLogger(__name__)
|
|||||||
__all__ = ["WebService", "build_app"]
|
__all__ = ["WebService", "build_app"]
|
||||||
|
|
||||||
|
|
||||||
def build_app(app: App, config: WebConfig, config_path: Path) -> tuple[FastAPI, StateHub]:
|
def build_app(
|
||||||
"""Assemble the ASGI app. Separate from the service so tests can drive it directly."""
|
app: App,
|
||||||
|
config: WebConfig,
|
||||||
|
config_path: Path,
|
||||||
|
ha_client: httpx2.AsyncClient | None = None,
|
||||||
|
) -> tuple[FastAPI, StateHub]:
|
||||||
|
"""Assemble the ASGI app. Separate from the service so tests can drive it directly.
|
||||||
|
|
||||||
|
``ha_client`` is the outbound client the room-control routes proxy Home Assistant
|
||||||
|
calls through; tests inject one on a mock transport, production gets a real one
|
||||||
|
that :class:`WebService` closes on shutdown.
|
||||||
|
"""
|
||||||
hub = StateHub(app)
|
hub = StateHub(app)
|
||||||
|
ha_client = ha_client or httpx2.AsyncClient(timeout=10.0)
|
||||||
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||||
api.include_router(build_router(app, hub, config_path))
|
api.state.ha_client = ha_client
|
||||||
|
api.include_router(build_router(app, hub, config_path, ha_client))
|
||||||
|
|
||||||
if config.static_dir is not None:
|
if config.static_dir is not None:
|
||||||
if config.static_dir.is_dir():
|
if config.static_dir.is_dir():
|
||||||
@@ -86,3 +99,4 @@ class WebService:
|
|||||||
position.cancel()
|
position.cancel()
|
||||||
serving.cancel()
|
serving.cancel()
|
||||||
self.hub.stop()
|
self.hub.stop()
|
||||||
|
await self.api.state.ha_client.aclose()
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ requires-python = ">=3.13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aiomqtt>=2.0",
|
"aiomqtt>=2.0",
|
||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
|
# Also what tests drive the ASGI app with - plain httpx is deprecated in favour of
|
||||||
|
# this for exactly that. Runtime uses it to proxy the room page's Home Assistant
|
||||||
|
# calls, so the long-lived token never has to leave the backend.
|
||||||
|
"httpx2>=2.12",
|
||||||
"mutagen>=1.47",
|
"mutagen>=1.47",
|
||||||
"pillow>=10.4",
|
"pillow>=10.4",
|
||||||
"pydantic>=2.7",
|
"pydantic>=2.7",
|
||||||
@@ -19,8 +23,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# httpx2 is what starlette.testclient wants now; plain httpx is deprecated there.
|
dev = ["mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"]
|
||||||
dev = ["httpx2>=2.12", "mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
musicmouse = "musicmouse.__main__:main"
|
musicmouse = "musicmouse.__main__:main"
|
||||||
|
|||||||
@@ -52,6 +52,75 @@ def test_web_section_is_optional(config_dir: Path) -> None:
|
|||||||
assert config.general.web.static_dir == (config_dir / "dist").resolve()
|
assert config.general.web.static_dir == (config_dir / "dist").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_section_is_optional(config_dir: Path) -> None:
|
||||||
|
assert load_config(write_config(config_dir, VALID_CONFIG)).general.ha is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
||||||
|
data = _config(
|
||||||
|
ha={
|
||||||
|
"url": "http://homeassistant.local:8123",
|
||||||
|
"token": "abc123",
|
||||||
|
"devices": [{"entity_id": "light.a"}],
|
||||||
|
"scenes": [{"entity_id": "scene.a"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
config = load_config(write_config(config_dir, data))
|
||||||
|
assert config.general.ha is not None
|
||||||
|
assert config.general.ha.devices[0].name is None
|
||||||
|
assert config.general.ha.scenes[0].name is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_devices_and_scenes_preserve_config_order(config_dir: Path) -> None:
|
||||||
|
data = _config(
|
||||||
|
ha={
|
||||||
|
"url": "http://homeassistant.local:8123",
|
||||||
|
"token": "abc123",
|
||||||
|
"devices": [
|
||||||
|
{"entity_id": "cover.rollo"},
|
||||||
|
{"entity_id": "light.deckenlampe"},
|
||||||
|
{"entity_id": "light.beyond_links"},
|
||||||
|
],
|
||||||
|
"scenes": [
|
||||||
|
{"entity_id": "scene.aufwachen"},
|
||||||
|
{"entity_id": "scene.lesen"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
config = load_config(write_config(config_dir, data))
|
||||||
|
ha = config.general.ha
|
||||||
|
assert ha is not None
|
||||||
|
assert [d.entity_id for d in ha.devices] == [
|
||||||
|
"cover.rollo",
|
||||||
|
"light.deckenlampe",
|
||||||
|
"light.beyond_links",
|
||||||
|
]
|
||||||
|
assert [s.entity_id for s in ha.scenes] == ["scene.aufwachen", "scene.lesen"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_requires_devices_or_scenes(config_dir: Path) -> None:
|
||||||
|
message = _error(
|
||||||
|
config_dir,
|
||||||
|
_config(ha={"url": "http://ha", "token": "abc", "devices": [], "scenes": []}),
|
||||||
|
)
|
||||||
|
assert "configure at least one device or scene" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_rejects_unknown_key_on_a_device(config_dir: Path) -> None:
|
||||||
|
message = _error(
|
||||||
|
config_dir,
|
||||||
|
_config(
|
||||||
|
ha={
|
||||||
|
"url": "http://ha",
|
||||||
|
"token": "abc",
|
||||||
|
"devices": [{"entity_id": "light.a", "kind": "beyond"}],
|
||||||
|
"scenes": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert "unknown option" in message
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- error paths
|
# --------------------------------------------------------------------- error paths
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
from collections.abc import AsyncIterator, Iterator
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx2
|
import httpx2
|
||||||
@@ -19,7 +20,7 @@ from musicmouse.config import WebConfig, load_config
|
|||||||
from musicmouse.services.web.service import build_app
|
from musicmouse.services.web.service import build_app
|
||||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
from tests.conftest import VALID_CONFIG, write_config
|
from tests.conftest import VALID_CONFIG, write_config
|
||||||
from tests.websocket_harness import websocket_connect
|
from tests.websocket_harness import WebSocketSession, websocket_connect
|
||||||
|
|
||||||
TRACK_SECONDS = 10.0
|
TRACK_SECONDS = 10.0
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def api(sim: Simulation, config_dir: Path) -> Iterator[FastAPI]:
|
async def api(sim: Simulation, config_dir: Path) -> AsyncIterator[FastAPI]:
|
||||||
"""The real ASGI app, on the test's own event loop."""
|
"""The real ASGI app, on the test's own event loop."""
|
||||||
application, hub = build_app(sim.app, WebConfig(), config_dir / "config.yml")
|
application, hub = build_app(sim.app, WebConfig(), config_dir / "config.yml")
|
||||||
hub.start()
|
hub.start()
|
||||||
@@ -46,6 +47,7 @@ def api(sim: Simulation, config_dir: Path) -> Iterator[FastAPI]:
|
|||||||
yield application
|
yield application
|
||||||
finally:
|
finally:
|
||||||
hub.stop()
|
hub.stop()
|
||||||
|
await application.state.ha_client.aclose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -71,7 +73,8 @@ async def test_library_lists_every_album_with_its_tracks(client: Client) -> None
|
|||||||
"Eule",
|
"Eule",
|
||||||
"Kinderparty Lieder",
|
"Kinderparty Lieder",
|
||||||
"Conni in den Bergen",
|
"Conni in den Bergen",
|
||||||
"Wissen macht Ah",
|
"Alt",
|
||||||
|
"Neu",
|
||||||
}
|
}
|
||||||
|
|
||||||
album = await album_by_title(client, "Conni in den Bergen")
|
album = await album_by_title(client, "Conni in den Bergen")
|
||||||
@@ -248,6 +251,23 @@ async def test_volume_needs_one_of_the_two_fields(client: Client) -> None:
|
|||||||
# ---------------------------------------------------------------------- websocket
|
# ---------------------------------------------------------------------- websocket
|
||||||
|
|
||||||
|
|
||||||
|
async def last_state(socket: WebSocketSession) -> dict:
|
||||||
|
"""Drain every currently queued frame and return the most recent state payload.
|
||||||
|
|
||||||
|
A single backend action can trigger more than one broadcast (e.g. a reaction to
|
||||||
|
``PlaybackChanged`` announcing its own state change); callers only care about
|
||||||
|
where things ended up, not how many frames it took to get there. Call only after
|
||||||
|
the bus has been drained, so every frame the action produced is already queued.
|
||||||
|
"""
|
||||||
|
state = None
|
||||||
|
while not socket.from_app.empty():
|
||||||
|
message = await socket.next_json()
|
||||||
|
if message["type"] == "state":
|
||||||
|
state = message["state"]
|
||||||
|
assert state is not None
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) -> None:
|
async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) -> None:
|
||||||
"""State events only fire on change, so a tab that connects mid-track needs this."""
|
"""State events only fire on change, so a tab that connects mid-track needs this."""
|
||||||
async with websocket_connect(api, "/api/ws") as socket:
|
async with websocket_connect(api, "/api/ws") as socket:
|
||||||
@@ -272,6 +292,26 @@ async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation
|
|||||||
assert message["state"]["active_figure"] == "fuchs"
|
assert message["state"]["active_figure"] == "fuchs"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_switching_albums_while_playing_reaches_every_client(
|
||||||
|
api: FastAPI, client: Client, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
"""Regression: playing album B from track 0 while album A was already playing used
|
||||||
|
to never broadcast, leaving PlayView stuck on album A until a reload."""
|
||||||
|
first = await album_by_title(client, "Fuchs")
|
||||||
|
second = await album_by_title(client, "Eule")
|
||||||
|
|
||||||
|
async with websocket_connect(api, "/api/ws") as socket:
|
||||||
|
await socket.next_json()
|
||||||
|
|
||||||
|
await client.post("/api/play", json={"album_id": first["id"]})
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert (await last_state(socket))["album_title"] == "Fuchs"
|
||||||
|
|
||||||
|
await client.post("/api/play", json={"album_id": second["id"]})
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert (await last_state(socket))["album_title"] == "Eule"
|
||||||
|
|
||||||
|
|
||||||
async def test_a_refresh_tells_the_clients_to_reload_the_library(
|
async def test_a_refresh_tells_the_clients_to_reload_the_library(
|
||||||
api: FastAPI, client: Client, sim: Simulation
|
api: FastAPI, client: Client, sim: Simulation
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -301,6 +341,137 @@ async def test_the_web_ui_follows_a_figure_placed_on_the_reader(
|
|||||||
assert state["album_title"] == "Fuchs"
|
assert state["album_title"] == "Fuchs"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------- ha
|
||||||
|
|
||||||
|
HA_URL = "http://homeassistant.local:8123"
|
||||||
|
HA_TOKEN = "abc123"
|
||||||
|
|
||||||
|
VALID_CONFIG_WITH_HA: dict = {
|
||||||
|
**VALID_CONFIG,
|
||||||
|
"general": {
|
||||||
|
**VALID_CONFIG["general"],
|
||||||
|
"ha": {
|
||||||
|
"url": HA_URL,
|
||||||
|
"token": HA_TOKEN,
|
||||||
|
"devices": [
|
||||||
|
{"entity_id": "cover.rollo", "name": "Rollo"},
|
||||||
|
{"entity_id": "light.deckenlampe"},
|
||||||
|
],
|
||||||
|
"scenes": [{"entity_id": "scene.lesen", "name": "Lesen"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHomeAssistant:
|
||||||
|
"""Records every request the proxy makes and answers a couple of fixed routes."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests: list[httpx2.Request] = []
|
||||||
|
self.unreachable = False
|
||||||
|
|
||||||
|
def handler(self, request: httpx2.Request) -> httpx2.Response:
|
||||||
|
self.requests.append(request)
|
||||||
|
if self.unreachable:
|
||||||
|
raise httpx2.ConnectError("mock: connection refused")
|
||||||
|
if request.url.path == "/api/states/light.deckenlampe":
|
||||||
|
return httpx2.Response(200, json={"entity_id": "light.deckenlampe", "state": "on"})
|
||||||
|
if request.url.path == "/api/states/light.missing":
|
||||||
|
return httpx2.Response(404, json={"message": "Entity not found"})
|
||||||
|
if request.url.path == "/api/services/light/turn_on":
|
||||||
|
return httpx2.Response(200, json=[{"entity_id": "light.deckenlampe", "state": "on"}])
|
||||||
|
return httpx2.Response(404, json={"message": "unhandled in test"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def fake_ha() -> FakeHomeAssistant:
|
||||||
|
return FakeHomeAssistant()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_with_ha(
|
||||||
|
config_dir: Path, fake_ha: FakeHomeAssistant
|
||||||
|
) -> AsyncIterator[httpx2.AsyncClient]:
|
||||||
|
"""A second client, built from a config with an ``ha:`` section and an ``ha_client``
|
||||||
|
pointed at ``fake_ha`` instead of the network, without touching the shared
|
||||||
|
``sim``/``api``/``client`` fixtures every other test in this file uses."""
|
||||||
|
config = load_config(write_config(config_dir, VALID_CONFIG_WITH_HA))
|
||||||
|
simulation = await build_simulation(config, track_duration=TRACK_SECONDS)
|
||||||
|
try:
|
||||||
|
ha_client = httpx2.AsyncClient(transport=httpx2.MockTransport(fake_ha.handler))
|
||||||
|
application, hub = build_app(
|
||||||
|
simulation.app, WebConfig(), config_dir / "config.yml", ha_client=ha_client
|
||||||
|
)
|
||||||
|
hub.start()
|
||||||
|
try:
|
||||||
|
transport = httpx2.ASGITransport(app=application)
|
||||||
|
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
|
||||||
|
yield http
|
||||||
|
finally:
|
||||||
|
hub.stop()
|
||||||
|
await ha_client.aclose()
|
||||||
|
finally:
|
||||||
|
await simulation.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ha_is_a_404_when_not_configured(client: Client) -> None:
|
||||||
|
assert (await client.get("/api/ha")).status_code == 404
|
||||||
|
assert (await client.get("/api/ha/states/light.x")).status_code == 404
|
||||||
|
assert (await client.post("/api/ha/services/light/turn_on", json={})).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ha_config_returns_ordered_lists_without_the_token(client_with_ha: Client) -> None:
|
||||||
|
body = (await client_with_ha.get("/api/ha")).json()
|
||||||
|
assert "url" not in body
|
||||||
|
assert "token" not in body
|
||||||
|
assert body["devices"] == [
|
||||||
|
{"entity_id": "cover.rollo", "name": "Rollo"},
|
||||||
|
{"entity_id": "light.deckenlampe", "name": None},
|
||||||
|
]
|
||||||
|
assert body["scenes"] == [{"entity_id": "scene.lesen", "name": "Lesen"}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ha_states_proxies_to_home_assistant_with_the_token_attached(
|
||||||
|
client_with_ha: Client, fake_ha: FakeHomeAssistant
|
||||||
|
) -> None:
|
||||||
|
response = await client_with_ha.get("/api/ha/states/light.deckenlampe")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"entity_id": "light.deckenlampe", "state": "on"}
|
||||||
|
|
||||||
|
assert len(fake_ha.requests) == 1
|
||||||
|
upstream = fake_ha.requests[0]
|
||||||
|
assert str(upstream.url) == f"{HA_URL}/api/states/light.deckenlampe"
|
||||||
|
assert upstream.headers["authorization"] == f"Bearer {HA_TOKEN}"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ha_states_passes_through_a_404_from_home_assistant(client_with_ha: Client) -> None:
|
||||||
|
response = await client_with_ha.get("/api/ha/states/light.missing")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ha_services_proxies_the_body_with_the_token_attached(
|
||||||
|
client_with_ha: Client, fake_ha: FakeHomeAssistant
|
||||||
|
) -> None:
|
||||||
|
response = await client_with_ha.post(
|
||||||
|
"/api/ha/services/light/turn_on",
|
||||||
|
json={"entity_id": "light.deckenlampe", "brightness_pct": 80},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
upstream = fake_ha.requests[0]
|
||||||
|
assert str(upstream.url) == f"{HA_URL}/api/services/light/turn_on"
|
||||||
|
assert upstream.headers["authorization"] == f"Bearer {HA_TOKEN}"
|
||||||
|
assert json.loads(upstream.content) == {"entity_id": "light.deckenlampe", "brightness_pct": 80}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ha_proxy_reports_an_unreachable_home_assistant_as_a_502(
|
||||||
|
client_with_ha: Client, fake_ha: FakeHomeAssistant
|
||||||
|
) -> None:
|
||||||
|
fake_ha.unreachable = True
|
||||||
|
response = await client_with_ha.get("/api/ha/states/light.deckenlampe")
|
||||||
|
assert response.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------- through a real uvicorn, not just ASGI
|
# ------------------------------------------------- through a real uvicorn, not just ASGI
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
web/public/dolphin-remote.png
Normal file
BIN
web/public/dolphin-remote.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
114
web/src/App.tsx
114
web/src/App.tsx
@@ -9,7 +9,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
import type { Album } from "./api/types";
|
import type { Album, HaConfig } from "./api/types";
|
||||||
import { AlbumModal } from "./components/AlbumModal";
|
import { AlbumModal } from "./components/AlbumModal";
|
||||||
import { AppHeader } from "./components/AppHeader";
|
import { AppHeader } from "./components/AppHeader";
|
||||||
import { BrowseView } from "./components/BrowseView";
|
import { BrowseView } from "./components/BrowseView";
|
||||||
@@ -18,19 +18,23 @@ import { HelpOverlay } from "./components/HelpOverlay";
|
|||||||
import { ParentPanel } from "./components/ParentPanel";
|
import { ParentPanel } from "./components/ParentPanel";
|
||||||
import { PlayerBar } from "./components/PlayerBar";
|
import { PlayerBar } from "./components/PlayerBar";
|
||||||
import { PlayView } from "./components/PlayView";
|
import { PlayView } from "./components/PlayView";
|
||||||
|
import { RoomView } from "./components/RoomView";
|
||||||
import { useGridColumns } from "./hooks/useGridColumns";
|
import { useGridColumns } from "./hooks/useGridColumns";
|
||||||
import { useLibrary } from "./hooks/useLibrary";
|
import { useLibrary } from "./hooks/useLibrary";
|
||||||
import { usePlaybackClock } from "./hooks/usePlaybackClock";
|
|
||||||
import { usePlayerState } from "./hooks/usePlayerState";
|
import { usePlayerState } from "./hooks/usePlayerState";
|
||||||
import type { Action, UiState } from "./lib/keyboard";
|
import type { Action, UiState } from "./lib/keyboard";
|
||||||
import { handleKey, initialUiState } from "./lib/keyboard";
|
import { handleKey, initialUiState } from "./lib/keyboard";
|
||||||
import { playPop } from "./lib/pop";
|
import { playPop } from "./lib/pop";
|
||||||
import type { Filter, Results, SongHit } from "./lib/search";
|
import type { Group, Results, SongHit } from "./lib/search";
|
||||||
import { results as computeResults } from "./lib/search";
|
import { groupOf, results as computeResults } from "./lib/search";
|
||||||
|
|
||||||
/** Volume when un-muting, matching the mockup. */
|
/** Volume when un-muting, matching the mockup. */
|
||||||
const UNMUTE_PERCENT = 60;
|
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() {
|
export function App() {
|
||||||
const [ui, setUi] = useState<UiState>(initialUiState);
|
const [ui, setUi] = useState<UiState>(initialUiState);
|
||||||
const library = useLibrary();
|
const library = useLibrary();
|
||||||
@@ -39,9 +43,15 @@ export function App() {
|
|||||||
const [parentMode, setParentMode] = useState(
|
const [parentMode, setParentMode] = useState(
|
||||||
() => new URLSearchParams(location.search).get("parentMode") === "1",
|
() => 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 state = connection.state;
|
||||||
const position = usePlaybackClock(state?.position ?? 0, state?.playing ?? false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
|
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
|
||||||
@@ -53,10 +63,10 @@ export function App() {
|
|||||||
albums: library.albums,
|
albums: library.albums,
|
||||||
search: ui.search,
|
search: ui.search,
|
||||||
mode: ui.mode,
|
mode: ui.mode,
|
||||||
filter: ui.filter,
|
group: ui.group,
|
||||||
category: ui.category,
|
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(
|
const byId = useMemo(
|
||||||
@@ -110,27 +120,50 @@ export function App() {
|
|||||||
break;
|
break;
|
||||||
case "next":
|
case "next":
|
||||||
playPop(260);
|
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();
|
void api.next();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "previous":
|
case "previous":
|
||||||
playPop(260);
|
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();
|
void api.previous();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "volume":
|
case "volume":
|
||||||
setVolume((state?.volume ?? 0) + action.delta);
|
setVolume((state?.volume ?? 0) + action.delta);
|
||||||
break;
|
break;
|
||||||
case "seek":
|
case "seek": {
|
||||||
if (state?.duration) {
|
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;
|
break;
|
||||||
|
}
|
||||||
case "pop":
|
case "pop":
|
||||||
playPop(action.freq);
|
playPop(action.freq);
|
||||||
break;
|
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.
|
// 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);
|
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);
|
playPop(380);
|
||||||
setUi((previous) => ({
|
setUi((previous) => ({ ...previous, group: null, category: null, selIndex: 0 }));
|
||||||
...previous,
|
|
||||||
filter,
|
|
||||||
selIndex: 0,
|
|
||||||
view: "browse",
|
|
||||||
category: null,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCategory = (key: string | null) => {
|
const onCategory = (key: string | null) => {
|
||||||
@@ -170,12 +202,28 @@ export function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onOpenAlbum = (album: Album, navIndex: number) => {
|
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);
|
playPop(420);
|
||||||
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex }));
|
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 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);
|
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -192,17 +240,29 @@ export function App() {
|
|||||||
flexDirection: "column",
|
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
|
<BrowseView
|
||||||
results={results}
|
results={results}
|
||||||
filter={ui.filter}
|
group={ui.group}
|
||||||
|
albums={library.albums}
|
||||||
mode={ui.mode}
|
mode={ui.mode}
|
||||||
search={ui.search}
|
search={ui.search}
|
||||||
category={ui.category}
|
category={ui.category}
|
||||||
selIndex={ui.selIndex}
|
selIndex={ui.selIndex}
|
||||||
currentAlbumId={state.album_id}
|
currentAlbumId={state.album_id}
|
||||||
gridRef={gridRef}
|
gridRef={gridRef}
|
||||||
onFilter={onFilter}
|
onEnterGroup={onEnterGroup}
|
||||||
|
onBackToRoot={onBackToRoot}
|
||||||
onCategory={onCategory}
|
onCategory={onCategory}
|
||||||
onOpenAlbum={onOpenAlbum}
|
onOpenAlbum={onOpenAlbum}
|
||||||
onPlaySong={onPlaySong}
|
onPlaySong={onPlaySong}
|
||||||
@@ -214,7 +274,6 @@ export function App() {
|
|||||||
<PlayView
|
<PlayView
|
||||||
state={state}
|
state={state}
|
||||||
album={currentAlbum}
|
album={currentAlbum}
|
||||||
position={position}
|
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
onNext={() => run([{ type: "next" }])}
|
onNext={() => run([{ type: "next" }])}
|
||||||
onPrevious={() => run([{ type: "previous" }])}
|
onPrevious={() => run([{ type: "previous" }])}
|
||||||
@@ -222,6 +281,14 @@ export function App() {
|
|||||||
onVolume={setVolume}
|
onVolume={setVolume}
|
||||||
onMute={onMute}
|
onMute={onMute}
|
||||||
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
|
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
|
<PlayerBar
|
||||||
state={state}
|
state={state}
|
||||||
album={currentAlbum}
|
album={currentAlbum}
|
||||||
position={position}
|
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
onNext={() => run([{ type: "next" }])}
|
onNext={() => run([{ type: "next" }])}
|
||||||
onPrevious={() => run([{ type: "previous" }])}
|
onPrevious={() => run([{ type: "previous" }])}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
|
/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
|
||||||
|
|
||||||
import type { Album, PlayerState, Settings } from "./types";
|
import type { Album, HaConfig, HaEntityState, PlayerState, Settings } from "./types";
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`/api${path}`, {
|
const response = await fetch(`/api${path}`, {
|
||||||
@@ -20,6 +20,33 @@ function post(path: string, body?: unknown): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `null` means the room-control page isn't configured, not an error - unlike
|
||||||
|
* `request()`, a 404 here is expected and shouldn't throw. */
|
||||||
|
async function fetchHaConfig(): Promise<HaConfig | null> {
|
||||||
|
const response = await fetch("/api/ha");
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (!response.ok) throw new Error(`GET /ha failed: ${response.status}`);
|
||||||
|
return (await response.json()) as HaConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
|
||||||
|
* right now" (the backend answers the latter with a 502) - the room page treats a
|
||||||
|
* device with no state the same way either way, rather than crashing on a poll. */
|
||||||
|
async function fetchHaState(entityId: string): Promise<HaEntityState | null> {
|
||||||
|
const response = await fetch(`/api/ha/states/${entityId}`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return (await response.json()) as HaEntityState;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEntityState>> {
|
||||||
|
const results = await Promise.all(entityIds.map(fetchHaState));
|
||||||
|
const byId: Record<string, HaEntityState> = {};
|
||||||
|
results.forEach((state, index) => {
|
||||||
|
if (state) byId[entityIds[index]!] = state;
|
||||||
|
});
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
|
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
|
||||||
state: () => request<PlayerState>("/state"),
|
state: () => request<PlayerState>("/state"),
|
||||||
@@ -38,6 +65,11 @@ export const api = {
|
|||||||
settings: () => request<Settings>("/settings"),
|
settings: () => request<Settings>("/settings"),
|
||||||
saveSettings: (settings: Settings) =>
|
saveSettings: (settings: Settings) =>
|
||||||
request<Settings>("/settings", { method: "PUT", body: JSON.stringify(settings) }),
|
request<Settings>("/settings", { method: "PUT", body: JSON.stringify(settings) }),
|
||||||
|
|
||||||
|
haConfig: fetchHaConfig,
|
||||||
|
haStates: fetchHaStates,
|
||||||
|
haCallService: (domain: string, service: string, body: Record<string, unknown>) =>
|
||||||
|
post(`/ha/services/${domain}/${service}`, body),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;
|
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;
|
||||||
|
|||||||
@@ -63,3 +63,23 @@ export type ServerMessage =
|
|||||||
| { type: "state"; state: PlayerState }
|
| { type: "state"; state: PlayerState }
|
||||||
| { type: "position"; position: number; duration: number }
|
| { type: "position"; position: number; duration: number }
|
||||||
| { type: "library" };
|
| { type: "library" };
|
||||||
|
|
||||||
|
export interface HaDevice {
|
||||||
|
entity_id: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No url/token here - the backend proxies Home Assistant calls and keeps the token to
|
||||||
|
* itself, so the browser only ever learns which entities exist. */
|
||||||
|
export interface HaConfig {
|
||||||
|
devices: HaDevice[];
|
||||||
|
scenes: HaDevice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Home Assistant entity's `state`/`attributes`, relayed byte-for-byte through
|
||||||
|
* `GET /api/ha/states/{entity_id}`. */
|
||||||
|
export interface HaEntityState {
|
||||||
|
entity_id: string;
|
||||||
|
state: string;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
mascot?: string;
|
||||||
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
||||||
status?: ReactNode;
|
status?: ReactNode;
|
||||||
|
/** A pill linking to the other page - there's no router, so it's a click handler
|
||||||
|
* rather than an `href`. */
|
||||||
|
link?: { label: string; onClick: () => void };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({ status }: Props) {
|
export function AppHeader({
|
||||||
|
title = "Musik Delphin",
|
||||||
|
mascot = "/dolphin-mascot.png",
|
||||||
|
status,
|
||||||
|
link,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -16,13 +26,30 @@ export function AppHeader({ status }: Props) {
|
|||||||
// The status bar is translucent in standalone mode, so make room for it.
|
// The status bar is translucent in standalone mode, so make room for it.
|
||||||
padding: "calc(18px + env(safe-area-inset-top)) 32px 6px",
|
padding: "calc(18px + env(safe-area-inset-top)) 32px 6px",
|
||||||
flex: "none",
|
flex: "none",
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
{link && (
|
||||||
src="/dolphin-mascot.png"
|
<button
|
||||||
alt=""
|
onClick={link.onClick}
|
||||||
style={{ width: 64, height: 64, objectFit: "contain" }}
|
style={{
|
||||||
/>
|
position: "absolute",
|
||||||
|
left: 24,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "oklch(97% 0.01 230 / .95)",
|
||||||
|
color: "oklch(30% 0.03 230)",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 14,
|
||||||
|
padding: "9px 16px",
|
||||||
|
borderRadius: 999,
|
||||||
|
boxShadow: "0 6px 18px oklch(15% 0.04 230 / .4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<img src={mascot} alt="" style={{ width: 64, height: 64, objectFit: "contain" }} />
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 34,
|
fontSize: 34,
|
||||||
@@ -31,7 +58,7 @@ export function AppHeader({ status }: Props) {
|
|||||||
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
|
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Musik Delphin
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{status}
|
{status}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
199
web/src/components/LightCard.tsx
Normal file
199
web/src/components/LightCard.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/** A `light.*` device card - color-capable and brightness-only lights share this one
|
||||||
|
* component, branching on capability rather than being two components, since HA
|
||||||
|
* reports the difference on the entity itself (`supported_color_modes`). */
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { HaDevice, HaEntityState } from "../api/types";
|
||||||
|
import { oklchToRgb } from "../lib/oklch";
|
||||||
|
|
||||||
|
const SWATCHES: { name: string; oklch: string }[] = [
|
||||||
|
{ name: "Warmweiß", oklch: "oklch(90% 0.06 85)" },
|
||||||
|
{ name: "Sonnengelb", oklch: "oklch(85% 0.16 95)" },
|
||||||
|
{ name: "Korallenrot", oklch: "oklch(65% 0.20 25)" },
|
||||||
|
{ name: "Delfinblau", oklch: "oklch(70% 0.15 235)" },
|
||||||
|
{ name: "Riffgrün", oklch: "oklch(75% 0.16 155)" },
|
||||||
|
{ name: "Quallenlila", oklch: "oklch(65% 0.18 310)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** `["brightness"]`/`["onoff"]` lights get the brightness row only (a `brightness_pct`
|
||||||
|
* sent to a plain on/off bulb is harmlessly ignored by Home Assistant). */
|
||||||
|
const COLOR_MODES = new Set(["hs", "rgb", "rgbw", "rgbww", "xy"]);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
device: HaDevice;
|
||||||
|
state: HaEntityState | undefined;
|
||||||
|
callService: (domain: string, service: string, body: Record<string, unknown>) => Promise<void>;
|
||||||
|
optimistic: (entityId: string, patch: Partial<HaEntityState>) => void;
|
||||||
|
onManualChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRgb(a: [number, number, number], b: [number, number, number]): boolean {
|
||||||
|
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LightCard({ device, state, callService, optimistic, onManualChange }: Props) {
|
||||||
|
const swatchRgb = useMemo(
|
||||||
|
() => SWATCHES.map((swatch) => ({ ...swatch, rgb: oklchToRgb(swatch.oklch) })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const on = state?.state === "on";
|
||||||
|
const modes = (state?.attributes.supported_color_modes as string[] | undefined) ?? [];
|
||||||
|
const isColor = modes.some((mode) => COLOR_MODES.has(mode));
|
||||||
|
const brightness = (state?.attributes.brightness as number | undefined) ?? 0; // 0..255
|
||||||
|
const level = Math.min(5, Math.max(0, Math.round((brightness / 255) * 5)));
|
||||||
|
const rgb = (state?.attributes.rgb_color as [number, number, number] | undefined) ?? [
|
||||||
|
255, 214, 140,
|
||||||
|
];
|
||||||
|
const tint = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||||
|
|
||||||
|
const turnOn = (body: Record<string, unknown> = {}) => {
|
||||||
|
onManualChange();
|
||||||
|
optimistic(device.entity_id, {
|
||||||
|
state: "on",
|
||||||
|
attributes: { ...state?.attributes, ...body },
|
||||||
|
});
|
||||||
|
void callService("light", "turn_on", { entity_id: device.entity_id, ...body });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
onManualChange();
|
||||||
|
if (on) {
|
||||||
|
optimistic(device.entity_id, { state: "off" });
|
||||||
|
void callService("light", "turn_off", { entity_id: device.entity_id });
|
||||||
|
} else {
|
||||||
|
turnOn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="room-card"
|
||||||
|
style={{
|
||||||
|
boxShadow: on
|
||||||
|
? `0 8px 24px var(--room-shadow), 0 0 0 3px color-mix(in srgb, ${tint} 55%, transparent)`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 16,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flex: "none",
|
||||||
|
background: on ? tint : "oklch(88% 0.02 300)",
|
||||||
|
color: on ? "oklch(28% 0.05 300)" : "oklch(55% 0.02 300)",
|
||||||
|
boxShadow: on
|
||||||
|
? `0 0 22px color-mix(in srgb, ${tint} 70%, transparent)`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isColor ? <PendantIcon /> : <CeilingIcon />}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, fontSize: 19, fontWeight: 900, color: "var(--room-ink)" }}>
|
||||||
|
{device.name ?? device.entity_id}
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch on={on} onClick={toggle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: 16, opacity: on ? 1 : 0.55 }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => turnOn({ brightness_pct: n * 20 })}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 46,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: on && level >= n ? tint : "oklch(88% 0.02 300)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isColor && (
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}>
|
||||||
|
{swatchRgb.map((swatch) => {
|
||||||
|
const selected = on && sameRgb(rgb, swatch.rgb);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={swatch.name}
|
||||||
|
title={swatch.name}
|
||||||
|
onClick={() => turnOn({ rgb_color: swatch.rgb })}
|
||||||
|
style={{
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: swatch.oklch,
|
||||||
|
border: selected
|
||||||
|
? "4px solid oklch(28% 0.05 300)"
|
||||||
|
: "4px solid oklch(96% 0.012 300 / .8)",
|
||||||
|
boxShadow: "0 3px 8px oklch(20% 0.03 300 / .35)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleSwitch({ on, onClick }: { on: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={on}
|
||||||
|
style={{
|
||||||
|
width: 74,
|
||||||
|
height: 42,
|
||||||
|
padding: 4,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: on ? "flex-end" : "flex-start",
|
||||||
|
background: on ? "var(--room-accent)" : "oklch(84% 0.01 300)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#fff",
|
||||||
|
boxShadow: "0 2px 6px oklch(20% 0.03 300 / .4)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendantIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<circle cx="24" cy="24" r="17" fill="none" stroke="currentColor" strokeWidth="3" />
|
||||||
|
<circle cx="24" cy="24" r="9" fill="currentColor" opacity=".85" />
|
||||||
|
<circle cx="24" cy="24" r="22" fill="none" stroke="currentColor" strokeWidth="1.5" opacity=".4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CeilingIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<path d="M24 4v7" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
<path d="M9 30 L24 11 L39 30 Z" fill="none" stroke="currentColor" strokeWidth="3" strokeLinejoin="round" />
|
||||||
|
<path d="M16 37h16" stroke="currentColor" strokeWidth="3" strokeLinecap="round" opacity=".55" />
|
||||||
|
<path d="M20 43h8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" opacity=".3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
web/src/components/RoomView.tsx
Normal file
91
web/src/components/RoomView.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/** The room-control page ("Mein Zimmer"). Owns its Home Assistant polling directly -
|
||||||
|
* unlike BrowseView/PlayView, its data source has nothing to do with the musicmouse
|
||||||
|
* player state that App.tsx otherwise orchestrates. Ported from
|
||||||
|
* `claude-design/Mein Zimmer.dc.html`. */
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { HaConfig } from "../api/types";
|
||||||
|
import { useHomeAssistant } from "../hooks/useHomeAssistant";
|
||||||
|
import { AppHeader } from "./AppHeader";
|
||||||
|
import { Bubbles } from "./Bubbles";
|
||||||
|
import { LightCard } from "./LightCard";
|
||||||
|
import { SceneRow } from "./SceneRow";
|
||||||
|
import { ShutterCard } from "./ShutterCard";
|
||||||
|
|
||||||
|
export function RoomView({ config, onBrowse }: { config: HaConfig; onBrowse: () => void }) {
|
||||||
|
const ha = useHomeAssistant(config);
|
||||||
|
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
||||||
|
// Cleared by any manual device change, set by activating a scene.
|
||||||
|
const [activeScene, setActiveScene] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onManualChange = () => setActiveScene(null);
|
||||||
|
|
||||||
|
const onActivateScene = (entityId: string) => {
|
||||||
|
setActiveScene(entityId);
|
||||||
|
void ha.callService("scene", "turn_on", { entity_id: entityId });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stage room-stage" style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<Bubbles />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppHeader
|
||||||
|
title="Mein Zimmer"
|
||||||
|
mascot="/dolphin-remote.png"
|
||||||
|
link={{ label: "♪ Musik", onClick: onBrowse }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 64px" }}>
|
||||||
|
<div style={{ maxWidth: 1180, margin: "0 auto" }}>
|
||||||
|
{config.scenes.length > 0 && (
|
||||||
|
<SceneRow
|
||||||
|
scenes={config.scenes}
|
||||||
|
activeScene={activeScene}
|
||||||
|
onActivate={onActivateScene}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="room-grid" style={{ marginTop: 30 }}>
|
||||||
|
{config.devices.map((device) => {
|
||||||
|
const state = ha.states[device.entity_id];
|
||||||
|
if (device.entity_id.startsWith("cover.")) {
|
||||||
|
return (
|
||||||
|
<ShutterCard
|
||||||
|
key={device.entity_id}
|
||||||
|
device={device}
|
||||||
|
state={state}
|
||||||
|
callService={ha.callService}
|
||||||
|
optimistic={ha.optimistic}
|
||||||
|
onManualChange={onManualChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (device.entity_id.startsWith("light.")) {
|
||||||
|
return (
|
||||||
|
<LightCard
|
||||||
|
key={device.entity_id}
|
||||||
|
device={device}
|
||||||
|
state={state}
|
||||||
|
callService={ha.callService}
|
||||||
|
optimistic={ha.optimistic}
|
||||||
|
onManualChange={onManualChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Not a domain this page knows how to draw - skip rather than crash.
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
web/src/components/SceneRow.tsx
Normal file
40
web/src/components/SceneRow.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { HaDevice } from "../api/types";
|
||||||
|
|
||||||
|
export function SceneRow({
|
||||||
|
scenes,
|
||||||
|
activeScene,
|
||||||
|
onActivate,
|
||||||
|
}: {
|
||||||
|
scenes: HaDevice[];
|
||||||
|
activeScene: string | null;
|
||||||
|
onActivate: (entityId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--room-paper)",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Szenen
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: 12 }}>
|
||||||
|
{scenes.map((scene) => (
|
||||||
|
<button
|
||||||
|
key={scene.entity_id}
|
||||||
|
className="room-pill"
|
||||||
|
data-active={scene.entity_id === activeScene}
|
||||||
|
onClick={() => onActivate(scene.entity_id)}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 22, marginRight: 8 }}>✨</span>
|
||||||
|
{scene.name ?? scene.entity_id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
web/src/components/ShutterCard.tsx
Normal file
201
web/src/components/ShutterCard.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/** A `cover.*` device card ("Rollo"). Real covers report their own movement and
|
||||||
|
* position, so this just reflects HA's state - no client-side movement animation like
|
||||||
|
* the design mockup used (it had no real backend to poll). */
|
||||||
|
|
||||||
|
import type { HaDevice, HaEntityState } from "../api/types";
|
||||||
|
import {
|
||||||
|
SHUTTER_PRESETS,
|
||||||
|
closedLabelFor,
|
||||||
|
closedPercentFromPosition,
|
||||||
|
positionFromClosedPercent,
|
||||||
|
} from "../lib/shutter";
|
||||||
|
|
||||||
|
//: Home Assistant's `CoverEntityFeature.SET_POSITION` bit.
|
||||||
|
const SUPPORT_SET_POSITION = 4;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
device: HaDevice;
|
||||||
|
state: HaEntityState | undefined;
|
||||||
|
callService: (domain: string, service: string, body: Record<string, unknown>) => Promise<void>;
|
||||||
|
optimistic: (entityId: string, patch: Partial<HaEntityState>) => void;
|
||||||
|
onManualChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShutterCard({ device, state, callService, optimistic, onManualChange }: Props) {
|
||||||
|
const position = state?.attributes.current_position as number | undefined;
|
||||||
|
const supportedFeatures = (state?.attributes.supported_features as number | undefined) ?? 0;
|
||||||
|
const supportsPosition = position != null || (supportedFeatures & SUPPORT_SET_POSITION) !== 0;
|
||||||
|
const closedPercent = closedPercentFromPosition(position ?? 0);
|
||||||
|
|
||||||
|
const moving = state?.state === "opening" ? "up" : state?.state === "closing" ? "down" : null;
|
||||||
|
const statusLabel =
|
||||||
|
moving === "down" ? "Fährt runter …" : moving === "up" ? "Fährt hoch …" : closedLabelFor(closedPercent);
|
||||||
|
|
||||||
|
const setPosition = (target: number) => {
|
||||||
|
onManualChange();
|
||||||
|
optimistic(device.entity_id, {
|
||||||
|
attributes: { ...state?.attributes, current_position: target },
|
||||||
|
});
|
||||||
|
void callService("cover", "set_cover_position", {
|
||||||
|
entity_id: device.entity_id,
|
||||||
|
position: target,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
onManualChange();
|
||||||
|
void callService("cover", "open_cover", { entity_id: device.entity_id });
|
||||||
|
};
|
||||||
|
const close = () => {
|
||||||
|
onManualChange();
|
||||||
|
void callService("cover", "close_cover", { entity_id: device.entity_id });
|
||||||
|
};
|
||||||
|
const stop = () => {
|
||||||
|
onManualChange();
|
||||||
|
void callService("cover", "stop_cover", { entity_id: device.entity_id });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="room-card">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 16,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flex: "none",
|
||||||
|
background: "oklch(88% 0.03 300)",
|
||||||
|
color: "oklch(35% 0.05 300)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlindsIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--room-ink)" }}>
|
||||||
|
{device.name ?? "Rollo"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "oklch(45% 0.03 300 / .8)" }}>
|
||||||
|
{statusLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 18, marginTop: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 104,
|
||||||
|
height: 140,
|
||||||
|
flex: "none",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "3px solid oklch(35% 0.04 300)",
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
background: "linear-gradient(180deg, oklch(82% 0.08 220), oklch(70% 0.09 200))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: `${closedPercent}%`,
|
||||||
|
background:
|
||||||
|
"repeating-linear-gradient(180deg, oklch(70% 0.03 300) 0px, oklch(70% 0.03 300) 9px, oklch(58% 0.03 300) 9px, oklch(58% 0.03 300) 12px)",
|
||||||
|
boxShadow: "0 4px 10px oklch(20% 0.03 300 / .4)",
|
||||||
|
transition: "height .2s linear",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{supportsPosition && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
|
{SHUTTER_PRESETS.map((preset) => {
|
||||||
|
const active = closedLabelFor(closedPercent) === preset.label;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
onClick={() => setPosition(positionFromClosedPercent(preset.closedPercent))}
|
||||||
|
style={{
|
||||||
|
height: 48,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 14,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 14,
|
||||||
|
background: active ? "var(--room-accent)" : "oklch(89% 0.02 300)",
|
||||||
|
color: active ? "#fff" : "oklch(28% 0.04 300)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={open}
|
||||||
|
style={{
|
||||||
|
height: 52,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 14,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: 20,
|
||||||
|
background: moving === "up" ? "var(--room-accent)" : "oklch(89% 0.02 300)",
|
||||||
|
color: moving === "up" ? "#fff" : "oklch(28% 0.04 300)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={stop}
|
||||||
|
style={{
|
||||||
|
height: 52,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 14,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: 18,
|
||||||
|
background: "oklch(65% 0.18 25)",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
■
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
style={{
|
||||||
|
height: 52,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 14,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: 20,
|
||||||
|
background: moving === "down" ? "var(--room-accent)" : "oklch(89% 0.02 300)",
|
||||||
|
color: moving === "down" ? "#fff" : "oklch(28% 0.04 300)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlindsIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<rect x="7" y="7" width="34" height="34" rx="4" fill="none" stroke="currentColor" strokeWidth="3" />
|
||||||
|
<path d="M7 16h34M7 24h34M7 32h34" stroke="currentColor" strokeWidth="2.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
web/src/hooks/useHomeAssistant.ts
Normal file
91
web/src/hooks/useHomeAssistant.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/** Polls the backend's Home Assistant proxy for the configured devices/scenes and
|
||||||
|
* exposes a `callService` escape hatch for commands. No websocket: HA's own
|
||||||
|
* auth/subscribe protocol is more machinery than a room panel needs - a short poll is
|
||||||
|
* plenty, and it's the backend, not the browser, doing the actual HA calls (see
|
||||||
|
* `GET/POST /api/ha/...` in the Python backend), so there's no token or Home
|
||||||
|
* Assistant URL here at all. */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { HaConfig, HaEntityState } from "../api/types";
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2500;
|
||||||
|
|
||||||
|
export interface HomeAssistant {
|
||||||
|
states: Record<string, HaEntityState>;
|
||||||
|
loading: boolean;
|
||||||
|
/** Patch one entity's cached state locally so a tap feels instant; the next poll
|
||||||
|
* reconciles with the truth. */
|
||||||
|
optimistic: (entityId: string, patch: Partial<HaEntityState>) => void;
|
||||||
|
callService: (domain: string, service: string, body: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
|
||||||
|
const [states, setStates] = useState<Record<string, HaEntityState>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// When each entity last got an optimistic patch. A poll that was already in flight
|
||||||
|
// when the patch landed resolves with pre-click data - without this, that stale
|
||||||
|
// response clobbers the optimistic "on" back to "off" until the *next* poll catches
|
||||||
|
// up, which is what made toggling feel laggy despite the optimistic update existing.
|
||||||
|
const optimisticAt = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
const entityIds = useMemo(
|
||||||
|
() => [...(config?.devices ?? []), ...(config?.scenes ?? [])].map((d) => d.entity_id),
|
||||||
|
[config],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const fresh = await api.haStates(entityIds);
|
||||||
|
if (cancelled) return;
|
||||||
|
setStates((previous) => {
|
||||||
|
const next = { ...previous };
|
||||||
|
for (const [entityId, state] of Object.entries(fresh)) {
|
||||||
|
// Only apply this result if no optimistic patch landed after the request
|
||||||
|
// for it went out - otherwise it's stale and would undo a newer change.
|
||||||
|
if (startedAt >= (optimisticAt.current[entityId] ?? 0)) {
|
||||||
|
next[entityId] = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
const id = setInterval(() => void poll(), POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [config, entityIds]);
|
||||||
|
|
||||||
|
const optimistic = useCallback((entityId: string, patch: Partial<HaEntityState>) => {
|
||||||
|
optimisticAt.current[entityId] = Date.now();
|
||||||
|
setStates((previous) => {
|
||||||
|
const current = previous[entityId];
|
||||||
|
return current ? { ...previous, [entityId]: { ...current, ...patch } } : previous;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const callService = useCallback(
|
||||||
|
(domain: string, service: string, body: Record<string, unknown>) => {
|
||||||
|
if (!config) return Promise.resolve();
|
||||||
|
// Card components fire this with `void` - swallow a failure here (e.g. HA
|
||||||
|
// unreachable, proxied as a 502) so it doesn't surface as an unhandled
|
||||||
|
// rejection; the next poll corrects any optimistic update that didn't take.
|
||||||
|
return api.haCallService(domain, service, body).catch((cause: unknown) => {
|
||||||
|
console.error(`${domain}.${service} failed:`, cause);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[config],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { states, loading, optimistic, callService };
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import type { Album } from "../../api/types";
|
import type { Album } from "../../api/types";
|
||||||
import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard";
|
import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard";
|
||||||
import { normalize, results as computeResults } from "../search";
|
import { normalize, results as computeResults, type Results } from "../search";
|
||||||
|
|
||||||
function album(id: string, over: Partial<Album> = {}): Album {
|
function album(id: string, over: Partial<Album> = {}): Album {
|
||||||
return {
|
return {
|
||||||
@@ -44,7 +44,7 @@ const resultsFor = (ui: UiState) =>
|
|||||||
albums: ALBUMS,
|
albums: ALBUMS,
|
||||||
search: ui.search,
|
search: ui.search,
|
||||||
mode: ui.mode,
|
mode: ui.mode,
|
||||||
filter: ui.filter,
|
group: ui.group,
|
||||||
category: ui.category,
|
category: ui.category,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,8 +59,9 @@ describe("normalize", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("search", () => {
|
describe("search", () => {
|
||||||
it("shows categories with no query, albums once there is one", () => {
|
it("shows no categories at the bare root, a group's own once one is chosen", () => {
|
||||||
expect(resultsFor(initialUiState).categories).toHaveLength(3);
|
expect(resultsFor(initialUiState).categories).toHaveLength(0);
|
||||||
|
expect(resultsFor({ ...initialUiState, group: "music" }).categories).toHaveLength(2);
|
||||||
expect(resultsFor(initialUiState).albums).toHaveLength(0);
|
expect(resultsFor(initialUiState).albums).toHaveLength(0);
|
||||||
|
|
||||||
const searching = { ...initialUiState, search: "conni" };
|
const searching = { ...initialUiState, search: "conni" };
|
||||||
@@ -68,7 +69,7 @@ describe("search", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("filters books and music apart", () => {
|
it("filters books and music apart", () => {
|
||||||
const books = resultsFor({ ...initialUiState, filter: "book", search: "a" });
|
const books = resultsFor({ ...initialUiState, group: "audiobooks", search: "a" });
|
||||||
expect(books.albums.every((a) => a.kind === "book")).toBe(true);
|
expect(books.albums.every((a) => a.kind === "book")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,19 +105,27 @@ describe("keyboard", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cycles the filter with TAB", () => {
|
it("cycles the group with TAB", () => {
|
||||||
expect(press("Tab")).toContainEqual({
|
expect(press("Tab")).toContainEqual({
|
||||||
type: "ui",
|
type: "ui",
|
||||||
patch: { filter: "music", selIndex: 0, view: "browse", category: null },
|
patch: { group: "music", selIndex: 0, view: "browse", category: null },
|
||||||
});
|
});
|
||||||
expect(press("Tab", { ...initialUiState, filter: "book" })).toContainEqual({
|
expect(press("Tab", { ...initialUiState, group: "podcasts" })).toContainEqual({
|
||||||
type: "ui",
|
type: "ui",
|
||||||
patch: { filter: "all", selIndex: 0, view: "browse", category: null },
|
patch: { group: "music", selIndex: 0, view: "browse", category: null },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives arrows to navigation while browsing and to transport while playing", () => {
|
it("jumps into the first group with arrows at the bare root", () => {
|
||||||
expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
|
expect(press("ArrowRight")).toEqual([
|
||||||
|
{ type: "pop", freq: 380 },
|
||||||
|
{ type: "ui", patch: { group: "music", selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives arrows to navigation once inside a group, and to transport while playing", () => {
|
||||||
|
const inGroup = { ...initialUiState, group: "music" as const };
|
||||||
|
expect(press("ArrowRight", inGroup)).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
|
||||||
|
|
||||||
const playing: UiState = { ...initialUiState, view: "play" };
|
const playing: UiState = { ...initialUiState, view: "play" };
|
||||||
expect(press("ArrowRight", playing)).toEqual([{ type: "next" }]);
|
expect(press("ArrowRight", playing)).toEqual([{ type: "next" }]);
|
||||||
@@ -134,16 +143,26 @@ describe("keyboard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("moves a whole row at a time in the grid", () => {
|
it("moves a whole row at a time in the grid", () => {
|
||||||
const ui = { ...initialUiState, cols: 2, selIndex: 0 };
|
const ui = { ...initialUiState, group: "music" as const, cols: 2, selIndex: 0 };
|
||||||
expect(handleKey(key("ArrowDown"), ui, resultsFor(ui))).toEqual([
|
const grid: Results = {
|
||||||
|
songs: [],
|
||||||
|
categories: [
|
||||||
|
{ key: "A", albums: [] },
|
||||||
|
{ key: "B", albums: [] },
|
||||||
|
{ key: "C", albums: [] },
|
||||||
|
],
|
||||||
|
albums: [],
|
||||||
|
total: 3,
|
||||||
|
};
|
||||||
|
expect(handleKey(key("ArrowDown"), ui, grid)).toEqual([
|
||||||
{ type: "ui", patch: { selIndex: 2 } },
|
{ type: "ui", patch: { selIndex: 2 } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clamps the selection to what is on screen", () => {
|
it("clamps the selection to what is on screen", () => {
|
||||||
const ui = { ...initialUiState, selIndex: 2 };
|
const ui = { ...initialUiState, group: "music" as const, selIndex: 5 };
|
||||||
expect(handleKey(key("ArrowRight"), ui, resultsFor(ui))).toEqual([
|
expect(handleKey(key("ArrowRight"), ui, resultsFor(ui))).toEqual([
|
||||||
{ type: "ui", patch: { selIndex: 2 } },
|
{ type: "ui", patch: { selIndex: 1 } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,15 +171,20 @@ describe("keyboard", () => {
|
|||||||
...initialUiState,
|
...initialUiState,
|
||||||
showHelp: true,
|
showHelp: true,
|
||||||
openAlbumId: "a",
|
openAlbumId: "a",
|
||||||
|
view: "play",
|
||||||
search: "x",
|
search: "x",
|
||||||
mode: "tracks",
|
mode: "tracks",
|
||||||
category: "Conni",
|
category: "Conni",
|
||||||
|
group: "audiobooks",
|
||||||
};
|
};
|
||||||
expect(press("Escape", deep)).toEqual([
|
expect(press("Escape", deep)).toEqual([
|
||||||
{ type: "ui", patch: { showHelp: false, openAlbumId: null } },
|
{ type: "ui", patch: { showHelp: false, openAlbumId: null } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const searching = { ...deep, showHelp: false, openAlbumId: null };
|
const inPlay = { ...deep, showHelp: false, openAlbumId: null };
|
||||||
|
expect(press("Escape", inPlay)).toEqual([{ type: "ui", patch: { view: "browse" } }]);
|
||||||
|
|
||||||
|
const searching = { ...inPlay, view: "browse" as const };
|
||||||
expect(press("Escape", searching)).toEqual([
|
expect(press("Escape", searching)).toEqual([
|
||||||
{ type: "ui", patch: { search: "", selIndex: 0 } },
|
{ type: "ui", patch: { search: "", selIndex: 0 } },
|
||||||
]);
|
]);
|
||||||
@@ -174,6 +198,16 @@ describe("keyboard", () => {
|
|||||||
expect(press("Escape", inCategory)).toEqual([
|
expect(press("Escape", inCategory)).toEqual([
|
||||||
{ type: "ui", patch: { category: null, selIndex: 0 } },
|
{ type: "ui", patch: { category: null, selIndex: 0 } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const inGroup = { ...inCategory, category: null };
|
||||||
|
expect(press("Escape", inGroup)).toEqual([
|
||||||
|
{ type: "ui", patch: { group: null, selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes the room view back to browse, like the play view", () => {
|
||||||
|
const inRoom: UiState = { ...initialUiState, view: "room" };
|
||||||
|
expect(press("Escape", inRoom)).toEqual([{ type: "ui", patch: { view: "browse" } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not swallow backspace when there is nothing to delete", () => {
|
it("does not swallow backspace when there is nothing to delete", () => {
|
||||||
@@ -210,7 +244,7 @@ describe("selectionAt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("opens a category rather than playing it", () => {
|
it("opens a category rather than playing it", () => {
|
||||||
const found = resultsFor(initialUiState);
|
const found = resultsFor({ ...initialUiState, group: "music" });
|
||||||
expect(selectionAt(found, 0)).toEqual({
|
expect(selectionAt(found, 0)).toEqual({
|
||||||
type: "ui",
|
type: "ui",
|
||||||
patch: { category: found.categories[0]!.key, selIndex: 0 },
|
patch: { category: found.categories[0]!.key, selIndex: 0 },
|
||||||
|
|||||||
51
web/src/lib/__tests__/shutter.test.ts
Normal file
51
web/src/lib/__tests__/shutter.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SHUTTER_PRESETS,
|
||||||
|
closedLabelFor,
|
||||||
|
closedPercentFromPosition,
|
||||||
|
positionFromClosedPercent,
|
||||||
|
} from "../shutter";
|
||||||
|
|
||||||
|
describe("closedPercentFromPosition", () => {
|
||||||
|
it("inverts HA's open-percent into the UI's closed-percent", () => {
|
||||||
|
expect(closedPercentFromPosition(100)).toBe(0); // fully open
|
||||||
|
expect(closedPercentFromPosition(0)).toBe(100); // fully closed
|
||||||
|
expect(closedPercentFromPosition(85)).toBe(15); // mostly open
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("positionFromClosedPercent", () => {
|
||||||
|
it("converts every preset back into HA's open-percent convention", () => {
|
||||||
|
expect(SHUTTER_PRESETS.map((p) => positionFromClosedPercent(p.closedPercent))).toEqual([
|
||||||
|
100, 50, 15, 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips with closedPercentFromPosition", () => {
|
||||||
|
expect(positionFromClosedPercent(closedPercentFromPosition(37))).toBe(37);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("closedLabelFor", () => {
|
||||||
|
it("labels a mostly-open cover (current_position: 85) as mostly open, not closed", () => {
|
||||||
|
// current_position: 85 means 85% *open* in HA's convention, i.e. 15% closed.
|
||||||
|
expect(closedLabelFor(closedPercentFromPosition(85))).toBe("15% zu");
|
||||||
|
expect(closedLabelFor(closedPercentFromPosition(85))).not.toBe("Fast zu");
|
||||||
|
expect(closedLabelFor(closedPercentFromPosition(85))).not.toBe("Ganz zu");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, "Offen"],
|
||||||
|
[1, "Offen"],
|
||||||
|
[39, "39% zu"],
|
||||||
|
[40, "Halb zu"],
|
||||||
|
[79, "Halb zu"],
|
||||||
|
[80, "Fast zu"],
|
||||||
|
[98, "Fast zu"],
|
||||||
|
[99, "Ganz zu"],
|
||||||
|
[100, "Ganz zu"],
|
||||||
|
])("closedPercent %i -> %s", (closedPercent, expected) => {
|
||||||
|
expect(closedLabelFor(closedPercent)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { Album } from "../api/types";
|
||||||
import type { Filter, Mode, Results } from "./search";
|
import type { Group, Mode, Results } from "./search";
|
||||||
|
|
||||||
export interface UiState {
|
export interface UiState {
|
||||||
search: string;
|
search: string;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
filter: Filter;
|
/** `null` is the bare root screen (three shelves); otherwise which one is open. */
|
||||||
|
group: Group | null;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
selIndex: number;
|
selIndex: number;
|
||||||
view: "browse" | "play";
|
view: "browse" | "play" | "room";
|
||||||
openAlbumId: string | null;
|
openAlbumId: string | null;
|
||||||
showHelp: boolean;
|
showHelp: boolean;
|
||||||
cols: number;
|
cols: number;
|
||||||
@@ -27,7 +28,7 @@ export interface UiState {
|
|||||||
export const initialUiState: UiState = {
|
export const initialUiState: UiState = {
|
||||||
search: "",
|
search: "",
|
||||||
mode: "albums",
|
mode: "albums",
|
||||||
filter: "all",
|
group: null,
|
||||||
category: null,
|
category: null,
|
||||||
selIndex: 0,
|
selIndex: 0,
|
||||||
view: "browse",
|
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. */
|
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
||||||
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
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 VOLUME_STEP = 10;
|
||||||
export const SEEK_STEP = 15;
|
export const SEEK_STEP = 15;
|
||||||
@@ -91,9 +92,18 @@ function escape(state: UiState): Action[] {
|
|||||||
if (state.showHelp || state.openAlbumId !== null) {
|
if (state.showHelp || state.openAlbumId !== null) {
|
||||||
return [{ type: "ui", patch: { showHelp: false, 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.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
||||||
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", 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 {
|
export interface KeyEvent {
|
||||||
@@ -129,10 +139,11 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
|
|||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "Tab": {
|
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 [
|
return [
|
||||||
{ type: "pop", freq: 380 },
|
{ 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 "/":
|
case "/":
|
||||||
@@ -142,15 +153,27 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
|
|||||||
|
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
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" }];
|
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
|
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" }];
|
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
|
||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
|
if (browsing && isRootShelf(state)) {
|
||||||
|
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||||
|
}
|
||||||
return browsing
|
return browsing
|
||||||
? moveSelection(state, results, 0, 1)
|
? moveSelection(state, results, 0, 1)
|
||||||
: [{ type: "volume", delta: -VOLUME_STEP }];
|
: [{ type: "volume", delta: -VOLUME_STEP }];
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
|
if (browsing && isRootShelf(state)) {
|
||||||
|
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||||
|
}
|
||||||
return browsing
|
return browsing
|
||||||
? moveSelection(state, results, 0, -1)
|
? moveSelection(state, results, 0, -1)
|
||||||
: [{ type: "volume", delta: VOLUME_STEP }];
|
: [{ type: "volume", delta: VOLUME_STEP }];
|
||||||
|
|||||||
15
web/src/lib/oklch.ts
Normal file
15
web/src/lib/oklch.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch. Rather than
|
||||||
|
* hand-transcribing the oklch->sRGB math, this leans on the browser's own CSS engine:
|
||||||
|
* set the string as an element's color and read back what the browser resolved it to.
|
||||||
|
* Only ever called a handful of times (once per fixed swatch), never per render. */
|
||||||
|
export function oklchToRgb(oklch: string): [number, number, number] {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.style.color = oklch;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const resolved = getComputedStyle(el).color;
|
||||||
|
document.body.removeChild(el);
|
||||||
|
|
||||||
|
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(resolved);
|
||||||
|
if (!match) return [255, 255, 255];
|
||||||
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||||
|
}
|
||||||
35
web/src/lib/shutter.ts
Normal file
35
web/src/lib/shutter.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/** Conversions between the room UI's shutter model and Home Assistant's `cover.*`
|
||||||
|
* convention. Home Assistant's `current_position`/`position` are percent *open* (100 =
|
||||||
|
* fully open, 0 = fully closed); the UI (following the design mockup) thinks in percent
|
||||||
|
* *closed*. Kept pure and separate because that inversion is easy to get backwards. */
|
||||||
|
|
||||||
|
export interface ShutterPreset {
|
||||||
|
label: string;
|
||||||
|
/** Percent closed: 0 = fully open, 100 = fully closed. */
|
||||||
|
closedPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHUTTER_PRESETS: ShutterPreset[] = [
|
||||||
|
{ label: "Offen", closedPercent: 0 },
|
||||||
|
{ label: "Halb zu", closedPercent: 50 },
|
||||||
|
{ label: "Fast zu", closedPercent: 85 },
|
||||||
|
{ label: "Ganz zu", closedPercent: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** HA's `current_position` -> the UI's "how closed" percent. */
|
||||||
|
export function closedPercentFromPosition(position: number): number {
|
||||||
|
return 100 - position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The UI's "how closed" percent -> the `position` `cover.set_cover_position` expects. */
|
||||||
|
export function positionFromClosedPercent(closedPercent: number): number {
|
||||||
|
return 100 - closedPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closedLabelFor(closedPercent: number): string {
|
||||||
|
if (closedPercent <= 1) return "Offen";
|
||||||
|
if (closedPercent >= 99) return "Ganz zu";
|
||||||
|
if (closedPercent >= 80) return "Fast zu";
|
||||||
|
if (closedPercent >= 40) return "Halb zu";
|
||||||
|
return `${Math.round(closedPercent)}% zu`;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import "./styles/app.css";
|
import "./styles/app.css";
|
||||||
|
import "./styles/room.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
54
web/src/styles/room.css
Normal file
54
web/src/styles/room.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/* "Mein Zimmer" (room control). Ported from claude-design/Mein Zimmer.dc.html - the
|
||||||
|
same oklch-everywhere approach as app.css, but its own violet hue (~298-310) rather
|
||||||
|
than app.css's teal (~210), so the two pages read as siblings, not one bleeding into
|
||||||
|
the other. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--room-ink: oklch(20% 0.03 300);
|
||||||
|
--room-paper: oklch(97% 0.01 302);
|
||||||
|
--room-accent: oklch(62% 0.15 302);
|
||||||
|
--room-card-bg: oklch(96% 0.012 300 / 0.55);
|
||||||
|
--room-shadow: oklch(15% 0.05 300 / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-stage {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
oklch(56% 0.14 305) 0%,
|
||||||
|
oklch(38% 0.12 300) 45%,
|
||||||
|
oklch(21% 0.08 298) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-card {
|
||||||
|
background: var(--room-card-bg);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
box-shadow: 0 8px 24px var(--room-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-pill {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: oklch(97% 0.01 302 / 0.18);
|
||||||
|
color: var(--room-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-pill[data-active="true"] {
|
||||||
|
background: var(--room-paper);
|
||||||
|
color: oklch(26% 0.05 300);
|
||||||
|
box-shadow: 0 6px 18px oklch(15% 0.05 300 / 0.4);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user