Port "Mein Zimmer" - the Home Assistant room panel
The second of the web front-end's three pages. Lights get a toggle, five brightness steps and six colours; shutters get four presets, up/stop/down and a window that reflects the position Home Assistant reports. Its own violet hue rather than the player's teal, so the two pages read as siblings rather than one bleeding into the other - the same reason room.css has its own tokens. The backend keeps Home Assistant's URL and token and relays the calls, so this client learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha means Home Assistant is not configured, which is a normal answer: the tab simply does not appear. Polled, not subscribed, and only while the page is on screen. HA's own auth-and- subscribe websocket is more machinery than a room panel needs, and a lamp's state is not worth a request every 2.5 seconds when nobody is looking at it - this device has a music player to stay out of the way of. A tap patches the entity locally and records when; a poll that was already in flight when the patch landed is dropped for that entity rather than putting the lamp back to "off" until the next one catches up. That reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried over for the same reason. Nothing on a shutter card animates locally. A real cover reports its own movement and position, and a client-side guess at where it will end up is precisely what would fight with that - the design mockup animated it because it had no backend to ask. Two additions beyond a straight port, both because a kiosk has no pointer: CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a front-end whose README claims every screen is reachable from the keyboard. Plain TAB was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere else. --page opens directly on a page, which a kiosk may well want and which is the only way in with neither keyboard nor pointer. Verified against the real Home Assistant: the shutter reported open and the lamp off, and both drew that way. Nothing was switched - those entities are hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,8 @@ kiosk, and the things making it sluggish are browser-shaped: a compositor decidi
|
||||
gets its own layer, a `requestAnimationFrame` loop that repaints the viewport, and a
|
||||
JPEG decode per album card per cold start.
|
||||
|
||||
Only the music player is here. The typing game (`Tippen`), the room-lights page, parent
|
||||
mode and the IR-remote assignment are all still web-only.
|
||||
The music player and the room panel ("Mein Zimmer"). The typing game (`Tippen`), parent
|
||||
mode and the IR-remote assignment are still web-only.
|
||||
|
||||
## Running it
|
||||
|
||||
@@ -17,6 +17,7 @@ Start the backend, then:
|
||||
nix-shell # cargo, the GL/X11/Wayland libs, and Nunito
|
||||
cargo run # windowed, FemtoVG
|
||||
cargo run -- --server http://musicdolphin:8080
|
||||
cargo run -- --page room # open on the room panel rather than the player
|
||||
```
|
||||
|
||||
Checks:
|
||||
@@ -126,6 +127,7 @@ one websocket connection and a trickle of covers.
|
||||
| `src/library.rs` | The search index and the browse hierarchy. Port of `web/src/lib/search.ts`. |
|
||||
| `src/keymap.rs` | The keyboard model, as a pure function. Port of `web/src/lib/keyboard.ts`. |
|
||||
| `src/covers.rs` | The thumbnail cache, and the generated stand-in art. |
|
||||
| `src/ha.rs` | Home Assistant through the backend's proxy, and the shutter/colour conversions. Port of `web/src/hooks/useHomeAssistant.ts` and `lib/shutter.ts`. |
|
||||
| `src/oklch.rs`, `src/format.rs` | Colour conversion; durations. |
|
||||
| `src/main.rs` | Wiring: worker channels in, Slint models out. |
|
||||
| `ui/*.slint` | Layout only. Nothing here formats a number or picks a word. |
|
||||
@@ -154,9 +156,31 @@ The same map as the web UI.
|
||||
| `SHIFT+ENTER` | open the track list |
|
||||
| `ESC` | back out one layer at a time |
|
||||
| `/` | back to browsing |
|
||||
| `CTRL+TAB` | cycle Musik / Mein Zimmer / Tippen |
|
||||
|
||||
Everything is clickable too.
|
||||
|
||||
`CTRL+TAB` is the one addition to the web front-end's map. Its tab rail is reachable
|
||||
only with a pointer, which sits badly with a device whose stated design is that every
|
||||
screen can be reached from the keyboard. Plain `TAB` was already the group cycle.
|
||||
|
||||
## Mein Zimmer
|
||||
|
||||
The Home Assistant room panel, in its own violet rather than the player's teal so the
|
||||
two read as siblings. Lights get a toggle, five brightness steps and six colours;
|
||||
shutters get four presets, up/stop/down and a window that reflects the reported
|
||||
position. Nothing is animated locally - a real cover reports its own movement, and
|
||||
guessing where it will end up is what would fight with that.
|
||||
|
||||
The whole page is a poll, not a subscription, and only while it is on screen: Home
|
||||
Assistant's own auth-and-subscribe websocket is more machinery than this needs, and a
|
||||
lamp is not worth a request every 2.5 s when nobody is looking. A tap patches the
|
||||
entity locally and remembers when, so the poll already in flight cannot undo it - the
|
||||
same reconciliation `useHomeAssistant.ts` documents, and for the same reason.
|
||||
|
||||
The tab appears only when the backend answers `GET /api/ha` with a config; a 404 there
|
||||
means Home Assistant is not set up, which is a normal answer rather than an error.
|
||||
|
||||
The special keys are named in `ui/app.slint`, where the `Key.*` constants live, and
|
||||
reach Rust as `"escape"`, `"left"`, `"enter"` and so on - so `src/keymap.rs` is testable
|
||||
without a window and without hardcoding keysym values.
|
||||
|
||||
@@ -222,6 +222,28 @@ impl Client {
|
||||
format!("{}/api/albums/{album_id}/cover", self.base)
|
||||
}
|
||||
|
||||
/// A GET that decodes JSON, with the status code kept in the error text so a
|
||||
/// caller that cares about 404 - several do, because "not configured" is a normal
|
||||
/// answer here - can tell it from a connection failure.
|
||||
pub(crate) fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
|
||||
self.agent
|
||||
.get(&format!("{}/api{path}", self.base))
|
||||
.call()
|
||||
.map_err(|e| format!("GET {path}: {e}"))?
|
||||
.into_json()
|
||||
.map_err(|e| format!("GET {path}: bad JSON: {e}"))
|
||||
}
|
||||
|
||||
/// A POST whose response body, if any, is not worth reading. Home Assistant's
|
||||
/// relayed replies are the only ones with a body, and nothing here reads it.
|
||||
pub(crate) fn post_json(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
|
||||
self.agent
|
||||
.post(&format!("{}/api{path}", self.base))
|
||||
.send_json(body)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("POST {path}: {e}"))
|
||||
}
|
||||
|
||||
pub fn library(&self) -> Result<Vec<Album>, Error> {
|
||||
let out: LibraryOut = self
|
||||
.agent
|
||||
|
||||
319
slint-frontend/src/ha.rs
Normal file
319
slint-frontend/src/ha.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
//! Home Assistant, through the backend's proxy.
|
||||
//!
|
||||
//! Ported from `web/src/hooks/useHomeAssistant.ts`. The browser never learns Home
|
||||
//! Assistant's URL or token and neither does this: the backend keeps both and relays
|
||||
//! `GET /api/ha/states/{id}` and `POST /api/ha/services/{domain}/{service}`, so all
|
||||
//! this client knows is which entities exist.
|
||||
//!
|
||||
//! A short poll rather than a subscription. HA's own auth/subscribe websocket is more
|
||||
//! machinery than a room panel needs, and the panel is only on screen while someone is
|
||||
//! looking at it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{Client, Error};
|
||||
|
||||
pub const POLL_INTERVAL: Duration = Duration::from_millis(2500);
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Device {
|
||||
pub entity_id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn label(&self) -> &str {
|
||||
self.name.as_deref().unwrap_or(&self.entity_id)
|
||||
}
|
||||
|
||||
pub fn domain(&self) -> &str {
|
||||
self.entity_id.split('.').next().unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
/// Presence-only, like the LIRC config - there is nothing secret in a list of entity
|
||||
/// ids. `None` from `GET /api/ha` means Home Assistant is not configured at all, which
|
||||
/// is a normal answer and hides the tab rather than being an error.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub devices: Vec<Device>,
|
||||
#[serde(default)]
|
||||
pub scenes: Vec<Device>,
|
||||
}
|
||||
|
||||
/// One entity's `state`/`attributes`, relayed byte-for-byte from Home Assistant.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct EntityState {
|
||||
#[serde(default)]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub attributes: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl EntityState {
|
||||
pub fn is_on(&self) -> bool {
|
||||
self.state == "on"
|
||||
}
|
||||
|
||||
fn number(&self, key: &str) -> Option<f64> {
|
||||
self.attributes.get(key)?.as_f64()
|
||||
}
|
||||
|
||||
/// 0..255 as Home Assistant reports it.
|
||||
pub fn brightness(&self) -> f64 {
|
||||
self.number("brightness").unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Percent *open*, which is HA's convention. The room UI thinks in percent closed -
|
||||
/// see `closed_percent`.
|
||||
pub fn cover_position(&self) -> Option<f64> {
|
||||
self.number("current_position")
|
||||
}
|
||||
|
||||
pub fn supported_features(&self) -> u64 {
|
||||
self.number("supported_features").unwrap_or(0.0) as u64
|
||||
}
|
||||
|
||||
pub fn rgb(&self) -> [u8; 3] {
|
||||
let Some(values) = self.attributes.get("rgb_color").and_then(|v| v.as_array()) else {
|
||||
// The warm default the web card falls back to, so an on-but-uncoloured lamp
|
||||
// is not drawn as if it were off.
|
||||
return [255, 214, 140];
|
||||
};
|
||||
let mut out = [255u8, 214, 140];
|
||||
for (slot, value) in out.iter_mut().zip(values) {
|
||||
if let Some(number) = value.as_f64() {
|
||||
*slot = number.clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether this light can be given a colour, as opposed to only a brightness. A
|
||||
/// `brightness_pct` sent to a plain on/off bulb is harmlessly ignored by HA, so
|
||||
/// only the colour row is worth branching on.
|
||||
pub fn is_color(&self) -> bool {
|
||||
const COLOR_MODES: [&str; 5] = ["hs", "rgb", "rgbw", "rgbww", "xy"];
|
||||
self.attributes
|
||||
.get("supported_color_modes")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|modes| {
|
||||
modes
|
||||
.iter()
|
||||
.filter_map(|m| m.as_str())
|
||||
.any(|m| COLOR_MODES.contains(&m))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- shutters --
|
||||
|
||||
/// Home Assistant's `CoverEntityFeature.SET_POSITION`.
|
||||
pub const SUPPORT_SET_POSITION: u64 = 4;
|
||||
|
||||
/// The four buttons on a shutter card, in percent *closed*.
|
||||
pub const SHUTTER_PRESETS: [(&str, f64); 4] = [
|
||||
("Offen", 0.0),
|
||||
("Halb zu", 50.0),
|
||||
("Fast zu", 85.0),
|
||||
("Ganz zu", 100.0),
|
||||
];
|
||||
|
||||
/// HA reports percent *open*; the UI, following the design, thinks in percent *closed*.
|
||||
/// The inversion lives in one place because it is easy to get backwards.
|
||||
pub fn closed_percent(position: f64) -> f64 {
|
||||
100.0 - position
|
||||
}
|
||||
|
||||
pub fn position_from_closed(closed: f64) -> f64 {
|
||||
100.0 - closed
|
||||
}
|
||||
|
||||
pub fn closed_label(closed: f64) -> String {
|
||||
if closed <= 1.0 {
|
||||
"Offen".into()
|
||||
} else if closed >= 99.0 {
|
||||
"Ganz zu".into()
|
||||
} else if closed >= 80.0 {
|
||||
"Fast zu".into()
|
||||
} else if closed >= 40.0 {
|
||||
"Halb zu".into()
|
||||
} else {
|
||||
format!("{}% zu", closed.round())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- colours --
|
||||
|
||||
/// The six colours the room page offers, with the oklch each was authored as. Kept as
|
||||
/// literals rather than converted at runtime for the same reason `ui/theme.slint` does:
|
||||
/// the oklch is the form the design is maintained in, and the sRGB is what gets drawn.
|
||||
pub const SWATCHES: [(&str, [u8; 3]); 6] = [
|
||||
("Warmweiß", [0xf0, 0xdc, 0xb1]), // oklch(90% 0.06 85)
|
||||
("Sonnengelb", [0xef, 0xcc, 0x36]), // oklch(85% 0.16 95)
|
||||
("Korallenrot", [0xf1, 0x4d, 0x4c]), // oklch(65% 0.20 25)
|
||||
("Delfinblau", [0x00, 0xab, 0xed]), // oklch(70% 0.15 235)
|
||||
("Riffgrün", [0x42, 0xcb, 0x80]), // oklch(75% 0.16 155)
|
||||
("Quallenlila", [0xb1, 0x6a, 0xe0]), // oklch(65% 0.18 310)
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------- client --
|
||||
|
||||
impl Client {
|
||||
/// `Ok(None)` when Home Assistant is not configured - a 404 here is a normal
|
||||
/// answer, and the room tab simply does not appear.
|
||||
pub fn ha_config(&self) -> Result<Option<Config>, Error> {
|
||||
match self.get_json::<Config>("/ha") {
|
||||
Ok(config) => Ok(Some(config)),
|
||||
Err(error) if error.contains("404") => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ha_state(&self, entity_id: &str) -> Result<EntityState, Error> {
|
||||
self.get_json(&format!("/ha/states/{entity_id}"))
|
||||
}
|
||||
|
||||
pub fn ha_call(
|
||||
&self,
|
||||
domain: &str,
|
||||
service: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
self.post_json(&format!("/ha/services/{domain}/{service}"), body)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the poll thread sends back.
|
||||
pub struct Snapshot {
|
||||
pub states: HashMap<String, EntityState>,
|
||||
/// When the request that produced this went out. The UI drops any entity whose
|
||||
/// optimistic patch is newer - see the note in `Room::apply`.
|
||||
pub started_at: Instant,
|
||||
}
|
||||
|
||||
pub enum Job {
|
||||
/// Poll these entities once, now.
|
||||
Poll(Vec<String>),
|
||||
Call {
|
||||
domain: String,
|
||||
service: String,
|
||||
body: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// One worker for both polling and commands, so a command can never overtake the poll
|
||||
/// that was meant to confirm it.
|
||||
pub fn spawn(
|
||||
client: Client,
|
||||
jobs: std::sync::mpsc::Receiver<Job>,
|
||||
deliver: impl Fn(Snapshot) + Send + 'static,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("ha".into())
|
||||
.spawn(move || {
|
||||
for job in jobs {
|
||||
match job {
|
||||
Job::Call {
|
||||
domain,
|
||||
service,
|
||||
body,
|
||||
} => {
|
||||
if let Err(error) = client.ha_call(&domain, &service, body) {
|
||||
// The next poll corrects whatever the optimistic update
|
||||
// guessed, so a failed call is worth a line and nothing more.
|
||||
eprintln!("{domain}.{service}: {error}");
|
||||
}
|
||||
}
|
||||
Job::Poll(entity_ids) => {
|
||||
let started_at = Instant::now();
|
||||
let mut states = HashMap::new();
|
||||
for entity_id in entity_ids {
|
||||
if let Ok(state) = client.ha_state(&entity_id) {
|
||||
states.insert(entity_id, state);
|
||||
}
|
||||
}
|
||||
deliver(Snapshot { states, started_at });
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawning the home assistant worker")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(json: serde_json::Value) -> EntityState {
|
||||
serde_json::from_value(json).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_percent_inverts_home_assistants_convention() {
|
||||
assert_eq!(closed_percent(100.0), 0.0); // fully open
|
||||
assert_eq!(closed_percent(0.0), 100.0); // fully closed
|
||||
assert_eq!(position_from_closed(85.0), 15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_label_names_the_presets_and_falls_back_to_a_number() {
|
||||
assert_eq!(closed_label(0.0), "Offen");
|
||||
assert_eq!(closed_label(100.0), "Ganz zu");
|
||||
assert_eq!(closed_label(85.0), "Fast zu");
|
||||
assert_eq!(closed_label(50.0), "Halb zu");
|
||||
assert_eq!(closed_label(20.0), "20% zu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colour_light_is_told_apart_by_its_modes() {
|
||||
assert!(state(serde_json::json!({
|
||||
"state": "on",
|
||||
"attributes": { "supported_color_modes": ["hs", "color_temp"] }
|
||||
}))
|
||||
.is_color());
|
||||
assert!(!state(serde_json::json!({
|
||||
"state": "on",
|
||||
"attributes": { "supported_color_modes": ["brightness"] }
|
||||
}))
|
||||
.is_color());
|
||||
assert!(!state(serde_json::json!({ "state": "on", "attributes": {} })).is_color());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_falls_back_to_a_warm_white_rather_than_black() {
|
||||
assert_eq!(
|
||||
state(serde_json::json!({"state":"on","attributes":{}})).rgb(),
|
||||
[255, 214, 140]
|
||||
);
|
||||
assert_eq!(
|
||||
state(serde_json::json!({
|
||||
"state": "on", "attributes": { "rgb_color": [10, 20, 30] }
|
||||
}))
|
||||
.rgb(),
|
||||
[10, 20, 30]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_attribute_reads_as_zero_rather_than_failing() {
|
||||
let s = state(serde_json::json!({ "state": "off", "attributes": {} }));
|
||||
assert_eq!(s.brightness(), 0.0);
|
||||
assert_eq!(s.cover_position(), None);
|
||||
assert_eq!(s.supported_features(), 0);
|
||||
assert!(!s.is_on());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_knows_its_domain_and_its_label() {
|
||||
let device: Device =
|
||||
serde_json::from_value(serde_json::json!({ "entity_id": "light.kitchen" })).unwrap();
|
||||
assert_eq!(device.domain(), "light");
|
||||
assert_eq!(device.label(), "light.kitchen");
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,39 @@ pub const SEEK_STEP: f64 = 15.0;
|
||||
/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards.
|
||||
pub const PAGE_ROWS: i32 = 3;
|
||||
|
||||
/// Which top-level page is showing. Orthogonal to everything below it, so switching
|
||||
/// pages and coming back leaves the music side exactly as it was.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
Music,
|
||||
Room,
|
||||
Typing,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
// A plain Unicode symbol rather than the emoji: that glyph carries its own
|
||||
// muted colour on some platforms and ignores `color`, which makes it
|
||||
// illegible on the rail's own background.
|
||||
Self::Music => "♪",
|
||||
Self::Room => "💡",
|
||||
Self::Typing => "⌨️",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Music => "Musik",
|
||||
Self::Room => "Mein Zimmer",
|
||||
Self::Typing => "Tippen",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UiState {
|
||||
pub page: Page,
|
||||
pub search: String,
|
||||
pub tracks_mode: bool,
|
||||
/// `None` is the bare root screen (three shelves); otherwise which one is open.
|
||||
@@ -42,6 +73,7 @@ pub struct UiState {
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page: Page::Music,
|
||||
search: String::new(),
|
||||
tracks_mode: false,
|
||||
group: None,
|
||||
@@ -89,7 +121,8 @@ impl UiState {
|
||||
|
||||
/// Whether anything is left to back out of - what the corner button's presence is.
|
||||
pub fn can_go_back(&self) -> bool {
|
||||
self.play_view
|
||||
self.page != Page::Music
|
||||
|| self.play_view
|
||||
|| self.open_album.is_some()
|
||||
|| !self.search.is_empty()
|
||||
|| self.tracks_mode
|
||||
@@ -270,6 +303,20 @@ pub fn handle_key(
|
||||
return vec![Action::Toggle];
|
||||
}
|
||||
|
||||
// Ctrl+Tab cycles the pages. This is an addition to the web front-end, where the
|
||||
// tab rail is reachable only with a pointer - which sits badly with a device whose
|
||||
// stated design is that every screen can be reached from the keyboard. Plain Tab
|
||||
// is already taken by the group cycle, and Ctrl+Tab is the idiom for the outer
|
||||
// one everywhere else.
|
||||
if ctrl && key == "tab" {
|
||||
state.page = match state.page {
|
||||
Page::Music => Page::Room,
|
||||
Page::Room => Page::Typing,
|
||||
Page::Typing => Page::Music,
|
||||
};
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the
|
||||
// arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump.
|
||||
if ctrl {
|
||||
@@ -389,8 +436,12 @@ pub fn handle_key(
|
||||
vec![]
|
||||
}
|
||||
"escape" => {
|
||||
// Peel one layer at a time rather than dumping you back at the top.
|
||||
if state.open_album.is_some() {
|
||||
// Peel one layer at a time rather than dumping you back at the top. The
|
||||
// page is the outermost layer: ESC on the room or typing page comes back
|
||||
// to the player, and only then starts unwinding the browse hierarchy.
|
||||
if state.page != Page::Music {
|
||||
state.page = Page::Music;
|
||||
} else if state.open_album.is_some() {
|
||||
state.open_album = None;
|
||||
} else if state.play_view {
|
||||
state.play_view = false;
|
||||
@@ -621,6 +672,37 @@ mod tests {
|
||||
assert!(!state.can_go_back());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_tab_cycles_the_pages_and_plain_tab_still_cycles_groups() {
|
||||
let mut state = UiState::default();
|
||||
for expected in [Page::Room, Page::Typing, Page::Music] {
|
||||
handle_key("tab", true, false, &mut state, &empty(), &[0, 0, 0], 0);
|
||||
assert_eq!(state.page, expected);
|
||||
}
|
||||
// And the outer cycle has not eaten the inner one.
|
||||
press("tab", &mut state);
|
||||
assert_eq!(state.group, Some(Group::Music));
|
||||
assert_eq!(state.page, Page::Music);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_comes_back_from_another_page_before_it_touches_the_browse_state() {
|
||||
let mut state = UiState {
|
||||
page: Page::Room,
|
||||
group: Some(Group::Music),
|
||||
..Default::default()
|
||||
};
|
||||
press("escape", &mut state);
|
||||
assert_eq!(state.page, Page::Music);
|
||||
assert_eq!(
|
||||
state.group,
|
||||
Some(Group::Music),
|
||||
"the player was left as it was"
|
||||
);
|
||||
press("escape", &mut state);
|
||||
assert_eq!(state.group, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_cycles_the_three_groups_and_wraps() {
|
||||
let mut state = UiState::default();
|
||||
|
||||
@@ -13,23 +13,25 @@
|
||||
mod api;
|
||||
mod covers;
|
||||
mod format;
|
||||
mod ha;
|
||||
mod keymap;
|
||||
mod library;
|
||||
mod oklch;
|
||||
mod ws;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{channel, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use slint::{ModelRc, SharedString, VecModel};
|
||||
|
||||
use api::{Album, Client, PlayerState};
|
||||
|
||||
use covers::Tier;
|
||||
use keymap::{Action, UiState};
|
||||
use keymap::{Action, Page, UiState};
|
||||
use library::{Group, Library, Query, Results, GROUPS};
|
||||
|
||||
slint::include_modules!();
|
||||
@@ -63,6 +65,9 @@ enum FromWorker {
|
||||
Library(Result<Vec<Album>, String>),
|
||||
Cover(covers::Ready),
|
||||
Ws(WsEvent),
|
||||
/// `None` means Home Assistant is not configured, which hides the room tab.
|
||||
HaConfig(Option<ha::Config>),
|
||||
HaStates(ha::Snapshot),
|
||||
}
|
||||
|
||||
enum WsEvent {
|
||||
@@ -90,6 +95,20 @@ struct App {
|
||||
/// False for exactly one render: the frame a track changes on, and the frame a seek
|
||||
/// lands on.
|
||||
smooth: bool,
|
||||
|
||||
// ------------------------------------------------------------ Mein Zimmer --
|
||||
ha: Sender<ha::Job>,
|
||||
ha_config: Option<ha::Config>,
|
||||
ha_states: HashMap<String, ha::EntityState>,
|
||||
/// 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 answer puts a lamp back to "off" until the next poll catches up, which
|
||||
/// is what made the web page feel laggy despite having optimistic updates.
|
||||
ha_patched_at: HashMap<String, Instant>,
|
||||
ha_loaded: bool,
|
||||
/// Home Assistant scenes have no "currently active" state of their own, so this is
|
||||
/// purely local: set by activating one, cleared by any manual device change.
|
||||
active_scene: Option<String>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -122,6 +141,41 @@ impl App {
|
||||
]
|
||||
}
|
||||
|
||||
fn ha_send(&self, job: ha::Job) {
|
||||
let _ = self.ha.send(job);
|
||||
}
|
||||
|
||||
/// Patch one entity locally so a tap feels instant, and remember when, so the poll
|
||||
/// already in flight cannot undo it.
|
||||
fn ha_optimistic(&mut self, entity_id: &str, patch: impl FnOnce(&mut ha::EntityState)) {
|
||||
self.ha_patched_at
|
||||
.insert(entity_id.to_string(), Instant::now());
|
||||
if let Some(state) = self.ha_states.get_mut(entity_id) {
|
||||
patch(state);
|
||||
}
|
||||
self.active_scene = None;
|
||||
}
|
||||
|
||||
fn ha_call(&self, domain: &str, service: &str, body: serde_json::Value) {
|
||||
self.ha_send(ha::Job::Call {
|
||||
domain: domain.into(),
|
||||
service: service.into(),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
fn ha_entity_ids(&self) -> Vec<String> {
|
||||
let Some(config) = &self.ha_config else {
|
||||
return Vec::new();
|
||||
};
|
||||
config
|
||||
.devices
|
||||
.iter()
|
||||
.chain(config.scenes.iter())
|
||||
.map(|d| d.entity_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply(&mut self, actions: Vec<Action>) {
|
||||
for action in actions {
|
||||
match action {
|
||||
@@ -409,6 +463,35 @@ fn render(app: &mut App, window: &AppWindow) {
|
||||
window.set_now(now);
|
||||
window.set_has_bar(has_album && !app.ui.play_view);
|
||||
|
||||
// The room tab only exists when Home Assistant is configured - the backend
|
||||
// answering 404 for /api/ha is how that is known.
|
||||
let tabs: Vec<Tab> = [Page::Music, Page::Room, Page::Typing]
|
||||
.into_iter()
|
||||
.filter(|page| *page != Page::Room || app.ha_config.is_some())
|
||||
.map(|page| Tab {
|
||||
id: match page {
|
||||
Page::Music => "music",
|
||||
Page::Room => "room",
|
||||
Page::Typing => "typing",
|
||||
}
|
||||
.into(),
|
||||
icon: page.icon().into(),
|
||||
label: page.label().into(),
|
||||
})
|
||||
.collect();
|
||||
window.set_tabs(ModelRc::new(VecModel::from(tabs)));
|
||||
|
||||
window.set_page(
|
||||
match app.ui.page {
|
||||
Page::Music => "music",
|
||||
Page::Room => "room",
|
||||
Page::Typing => "typing",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
render_room(app, window);
|
||||
|
||||
let root_screen = app.ui.is_root_shelf();
|
||||
window.set_root_screen(root_screen);
|
||||
window.set_shelf_row(app.ui.shelf_row as i32);
|
||||
@@ -573,6 +656,94 @@ fn render_results(app: &mut App, window: &AppWindow, cols: usize) {
|
||||
window.set_grid(ModelRc::new(VecModel::from(grid)));
|
||||
}
|
||||
|
||||
/// Build the room page's models. One `RoomCard` list in Home Assistant's own config
|
||||
/// order, because that order is the only layout information the config carries.
|
||||
fn render_room(app: &mut App, window: &AppWindow) {
|
||||
let Some(config) = app.ha_config.clone() else {
|
||||
return;
|
||||
};
|
||||
window.set_room_loading(!app.ha_loaded);
|
||||
|
||||
let mut cards = Vec::with_capacity(config.devices.len());
|
||||
for device in &config.devices {
|
||||
let state = app.ha_states.get(&device.entity_id);
|
||||
let mut card = RoomCard {
|
||||
entity_id: device.entity_id.as_str().into(),
|
||||
name: device.label().into(),
|
||||
..Default::default()
|
||||
};
|
||||
match device.domain() {
|
||||
"light" => {
|
||||
let on = state.is_some_and(|s| s.is_on());
|
||||
let rgb = state.map_or([255, 214, 140], |s| s.rgb());
|
||||
card.kind = "light".into();
|
||||
card.on = on;
|
||||
card.is_color = state.is_some_and(|s| s.is_color());
|
||||
// 0..5, matching the five buttons - HA reports 0..255.
|
||||
card.level = state
|
||||
.map_or(0.0, |s| (s.brightness() / 255.0 * 5.0).round())
|
||||
.clamp(0.0, 5.0) as i32;
|
||||
card.tint = slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]);
|
||||
card.swatch = ha::SWATCHES
|
||||
.iter()
|
||||
.position(|(_, swatch)| *swatch == rgb)
|
||||
.map_or(-1, |i| i as i32);
|
||||
}
|
||||
"cover" => {
|
||||
let position = state.and_then(|s| s.cover_position());
|
||||
let features = state.map_or(0, |s| s.supported_features());
|
||||
let closed = ha::closed_percent(position.unwrap_or(0.0));
|
||||
let moving = match state.map(|s| s.state.as_str()) {
|
||||
Some("opening") => "up",
|
||||
Some("closing") => "down",
|
||||
_ => "",
|
||||
};
|
||||
card.kind = "shutter".into();
|
||||
card.closed_percent = closed as f32;
|
||||
card.moving = moving.into();
|
||||
card.status = match moving {
|
||||
"down" => "Fährt runter …".to_string(),
|
||||
"up" => "Fährt hoch …".to_string(),
|
||||
_ => ha::closed_label(closed),
|
||||
}
|
||||
.into();
|
||||
card.supports_position =
|
||||
position.is_some() || features & ha::SUPPORT_SET_POSITION != 0;
|
||||
let label = ha::closed_label(closed);
|
||||
card.preset = ha::SHUTTER_PRESETS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == label)
|
||||
.map_or(-1, |i| i as i32);
|
||||
}
|
||||
// Not a domain this page knows how to draw - skip rather than guess.
|
||||
_ => continue,
|
||||
}
|
||||
cards.push(card);
|
||||
}
|
||||
|
||||
let scenes: Vec<SceneTile> = config
|
||||
.scenes
|
||||
.iter()
|
||||
.map(|scene| SceneTile {
|
||||
entity_id: scene.entity_id.as_str().into(),
|
||||
name: scene.label().into(),
|
||||
active: app.active_scene.as_deref() == Some(scene.entity_id.as_str()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Three across at 1080p, which is what the web grid's `minmax(300px, 1fr)` inside
|
||||
// a 1180px column comes to on the same screen.
|
||||
let columns = ((window.get_grid_width() / 400.0).floor() as usize).clamp(1, 3);
|
||||
let rows: Vec<RoomRow> = cards
|
||||
.chunks(columns)
|
||||
.map(|chunk| RoomRow {
|
||||
cards: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||||
})
|
||||
.collect();
|
||||
window.set_room_rows(ModelRc::new(VecModel::from(rows)));
|
||||
window.set_room_scenes(ModelRc::new(VecModel::from(scenes)));
|
||||
}
|
||||
|
||||
fn render_sheet(app: &mut App, window: &AppWindow) {
|
||||
let Some(album) = app
|
||||
.ui
|
||||
@@ -631,10 +802,16 @@ fn render_sheet(app: &mut App, window: &AppWindow) {
|
||||
// ----------------------------------------------------------------------- wiring --
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let base = std::env::args()
|
||||
.skip_while(|a| a != "--server")
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
|
||||
let arg = |name: &str| -> Option<String> { std::env::args().skip_while(|a| a != name).nth(1) };
|
||||
let base = arg("--server").unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
|
||||
// Which page to open on. A kiosk may well want to come up on the room panel rather
|
||||
// than the player, and it is the only way to reach a page without a keyboard or a
|
||||
// pointer.
|
||||
let page = match arg("--page").as_deref() {
|
||||
Some("room") => Page::Room,
|
||||
Some("typing") => Page::Typing,
|
||||
_ => Page::Music,
|
||||
};
|
||||
println!("musicmouse-slint → {base}");
|
||||
|
||||
// Nunito is the face the design is drawn in, vendored as a TTF in `fonts/` because
|
||||
@@ -668,6 +845,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (to_ui, from_workers) = channel::<FromWorker>();
|
||||
let (cover_jobs, cover_rx) = channel::<covers::Job>();
|
||||
let (commands, command_rx) = channel::<Command>();
|
||||
let (ha_jobs, ha_rx) = channel::<ha::Job>();
|
||||
|
||||
// Covers: fetch, downscale, cache to disk. Never on the UI thread.
|
||||
{
|
||||
@@ -711,6 +889,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
})?;
|
||||
}
|
||||
|
||||
// Home Assistant, behind the backend's proxy. One worker for both the poll and
|
||||
// the commands, so a command can never overtake the poll meant to confirm it.
|
||||
{
|
||||
let states_tx = to_ui.clone();
|
||||
ha::spawn(client.clone(), ha_rx, move |snapshot| {
|
||||
let _ = states_tx.send(FromWorker::HaStates(snapshot));
|
||||
});
|
||||
let client = client.clone();
|
||||
let to_ui = to_ui.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ha-config".into())
|
||||
.spawn(move || {
|
||||
// A 404 here means Home Assistant is not configured, which is a normal
|
||||
// answer: the tab simply does not appear.
|
||||
let _ = to_ui.send(FromWorker::HaConfig(client.ha_config().ok().flatten()));
|
||||
})?;
|
||||
}
|
||||
|
||||
// State.
|
||||
{
|
||||
let to_ui = to_ui.clone();
|
||||
@@ -738,10 +934,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let app = Rc::new(RefCell::new(App {
|
||||
commands,
|
||||
ui: UiState::default(),
|
||||
ui: UiState {
|
||||
page,
|
||||
..UiState::default()
|
||||
},
|
||||
player: PlayerState::default(),
|
||||
library: Library::default(),
|
||||
covers: covers::Store::new(cover_jobs),
|
||||
ha: ha_jobs,
|
||||
ha_config: None,
|
||||
ha_states: HashMap::new(),
|
||||
ha_patched_at: HashMap::new(),
|
||||
ha_loaded: false,
|
||||
active_scene: None,
|
||||
online: false,
|
||||
loaded: false,
|
||||
error: None,
|
||||
@@ -749,6 +954,45 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
smooth: true,
|
||||
}));
|
||||
|
||||
// The room page's poll. Only runs while that page is showing: a lamp's state is
|
||||
// not worth a request every 2.5 s when nobody is looking at it, and this device
|
||||
// has a music player to get out of the way of.
|
||||
let poll = slint::Timer::default();
|
||||
{
|
||||
let handle = Rc::clone(&app);
|
||||
let weak = window.as_weak();
|
||||
poll.start(slint::TimerMode::Repeated, ha::POLL_INTERVAL, move || {
|
||||
let Some(window) = weak.upgrade() else { return };
|
||||
let entity_ids = {
|
||||
let app = handle.borrow();
|
||||
if app.ui.page != Page::Room || app.ha_config.is_none() {
|
||||
return;
|
||||
}
|
||||
app.ha_entity_ids()
|
||||
};
|
||||
handle.borrow().ha_send(ha::Job::Poll(entity_ids));
|
||||
let _ = &window;
|
||||
});
|
||||
}
|
||||
|
||||
// The static parts of the room page - the six colours and the four shutter
|
||||
// presets - never change, so they are set once rather than rebuilt per render.
|
||||
window.set_room_swatches(ModelRc::new(VecModel::from(
|
||||
ha::SWATCHES
|
||||
.iter()
|
||||
.map(|(name, rgb)| Swatch {
|
||||
name: (*name).into(),
|
||||
tint: slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)));
|
||||
window.set_shutter_presets(ModelRc::new(VecModel::from(
|
||||
ha::SHUTTER_PRESETS
|
||||
.iter()
|
||||
.map(|(label, _)| SharedString::from(*label))
|
||||
.collect::<Vec<_>>(),
|
||||
)));
|
||||
|
||||
install_callbacks(&app, &window);
|
||||
pump_workers(&app, &window, from_workers);
|
||||
|
||||
@@ -846,6 +1090,23 @@ fn handle_worker_message(app: &mut App, message: FromWorker) {
|
||||
}
|
||||
app.smooth = true;
|
||||
}
|
||||
FromWorker::HaConfig(config) => {
|
||||
app.ha_config = config;
|
||||
if app.ha_config.is_some() {
|
||||
app.ha_send(ha::Job::Poll(app.ha_entity_ids()));
|
||||
}
|
||||
}
|
||||
FromWorker::HaStates(snapshot) => {
|
||||
for (entity_id, state) in snapshot.states {
|
||||
// Drop any entity whose optimistic patch is newer than the request
|
||||
// that produced this - see `ha_patched_at`.
|
||||
let patched = app.ha_patched_at.get(&entity_id).copied();
|
||||
if patched.is_none_or(|at| snapshot.started_at >= at) {
|
||||
app.ha_states.insert(entity_id, state);
|
||||
}
|
||||
}
|
||||
app.ha_loaded = true;
|
||||
}
|
||||
FromWorker::Ws(WsEvent::LibraryChanged) => {
|
||||
// A rescan is the one moment the backend rewrites the art behind an
|
||||
// unchanged cover URL, so the thumbnails go with it.
|
||||
@@ -987,6 +1248,109 @@ fn install_callbacks(app: &Rc<RefCell<App>>, window: &AppWindow) {
|
||||
a.ui.open_album = None;
|
||||
});
|
||||
|
||||
bind!(on_select_page, |a, page: SharedString| {
|
||||
a.ui.page = match page.as_str() {
|
||||
"room" => Page::Room,
|
||||
"typing" => Page::Typing,
|
||||
_ => Page::Music,
|
||||
};
|
||||
// Ask for fresh state the moment the page comes up, rather than making the
|
||||
// first thing you see be up to one poll interval stale.
|
||||
if a.ui.page == Page::Room && a.ha_config.is_some() {
|
||||
a.ha_send(ha::Job::Poll(a.ha_entity_ids()));
|
||||
}
|
||||
});
|
||||
|
||||
bind!(on_toggle_light, |a, id: SharedString| {
|
||||
let on = a.ha_states.get(id.as_str()).is_some_and(|s| s.is_on());
|
||||
a.ha_optimistic(id.as_str(), |state| {
|
||||
state.state = if on { "off".into() } else { "on".into() };
|
||||
});
|
||||
a.ha_call(
|
||||
"light",
|
||||
if on { "turn_off" } else { "turn_on" },
|
||||
serde_json::json!({ "entity_id": id.as_str() }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_set_light_level, |a, id: SharedString, level: i32| {
|
||||
let percent = level.clamp(1, 5) * 20;
|
||||
a.ha_optimistic(id.as_str(), |state| {
|
||||
state.state = "on".into();
|
||||
state.attributes.insert(
|
||||
"brightness".into(),
|
||||
serde_json::json!((percent as f64 / 100.0 * 255.0).round()),
|
||||
);
|
||||
});
|
||||
a.ha_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
serde_json::json!({ "entity_id": id.as_str(), "brightness_pct": percent }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_set_light_swatch, |a, id: SharedString, index: i32| {
|
||||
let Some((_, rgb)) = ha::SWATCHES.get(index.max(0) as usize) else {
|
||||
return;
|
||||
};
|
||||
let rgb = *rgb;
|
||||
a.ha_optimistic(id.as_str(), |state| {
|
||||
state.state = "on".into();
|
||||
state.attributes.insert(
|
||||
"rgb_color".into(),
|
||||
serde_json::json!([rgb[0], rgb[1], rgb[2]]),
|
||||
);
|
||||
});
|
||||
a.ha_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
serde_json::json!({ "entity_id": id.as_str(), "rgb_color": [rgb[0], rgb[1], rgb[2]] }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_set_shutter_preset, |a, id: SharedString, index: i32| {
|
||||
let Some((_, closed)) = ha::SHUTTER_PRESETS.get(index.max(0) as usize) else {
|
||||
return;
|
||||
};
|
||||
let position = ha::position_from_closed(*closed);
|
||||
a.ha_optimistic(id.as_str(), |state| {
|
||||
state
|
||||
.attributes
|
||||
.insert("current_position".into(), serde_json::json!(position));
|
||||
});
|
||||
a.ha_call(
|
||||
"cover",
|
||||
"set_cover_position",
|
||||
serde_json::json!({ "entity_id": id.as_str(), "position": position }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_shutter, |a, id: SharedString, what: SharedString| {
|
||||
let service = match what.as_str() {
|
||||
"open" => "open_cover",
|
||||
"close" => "close_cover",
|
||||
_ => "stop_cover",
|
||||
};
|
||||
// No optimistic position here: a real cover reports its own movement, and
|
||||
// guessing where it will end up is exactly what would fight with that.
|
||||
a.active_scene = None;
|
||||
a.ha_patched_at.remove(id.as_str());
|
||||
a.ha_call(
|
||||
"cover",
|
||||
service,
|
||||
serde_json::json!({ "entity_id": id.as_str() }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_activate_scene, |a, id: SharedString| {
|
||||
a.active_scene = Some(id.to_string());
|
||||
a.ha_call(
|
||||
"scene",
|
||||
"turn_on",
|
||||
serde_json::json!({ "entity_id": id.as_str() }),
|
||||
);
|
||||
});
|
||||
|
||||
bind!(on_play_sheet_track, |a, index: i32| {
|
||||
if let Some(album_id) = a.ui.open_album.clone() {
|
||||
a.apply(vec![Action::Play {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Theme } from "theme.slint";
|
||||
import { RoundButton } from "widgets.slint";
|
||||
import { ShelfView, ResultsView } from "browse.slint";
|
||||
import { PlayerBar, PlayView, AlbumSheet } from "player.slint";
|
||||
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying } from "models.slint";
|
||||
import { RoomView } from "room.slint";
|
||||
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab } from "models.slint";
|
||||
|
||||
export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, Theme }
|
||||
export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab, Theme }
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
title: "Musik Delfin";
|
||||
@@ -20,6 +21,17 @@ export component AppWindow inherits Window {
|
||||
Theme.sea-deep 100%);
|
||||
|
||||
// ------------------------------------------------------------------- inputs --
|
||||
// "music" | "room" | "typing" - named rather than an enum so Rust owns the
|
||||
// ordering and the labels, as it does for every other string on screen.
|
||||
in property <string> page: "music";
|
||||
in property <[Tab]> tabs;
|
||||
|
||||
in property <[RoomRow]> room-rows;
|
||||
in property <[SceneTile]> room-scenes;
|
||||
in property <[Swatch]> room-swatches;
|
||||
in property <[string]> shutter-presets;
|
||||
in property <bool> room-loading: true;
|
||||
|
||||
in property <bool> loading: true;
|
||||
in property <string> splash-text;
|
||||
in property <string> status-label;
|
||||
@@ -87,6 +99,13 @@ export component AppWindow inherits Window {
|
||||
callback open-play-view();
|
||||
callback close-sheet();
|
||||
callback play-sheet-track(int);
|
||||
callback select-page(string);
|
||||
callback toggle-light(string);
|
||||
callback set-light-level(string, int);
|
||||
callback set-light-swatch(string, int);
|
||||
callback set-shutter-preset(string, int);
|
||||
callback shutter(string, string);
|
||||
callback activate-scene(string);
|
||||
|
||||
forward-focus: keys;
|
||||
|
||||
@@ -111,7 +130,7 @@ export component AppWindow inherits Window {
|
||||
|
||||
VerticalLayout {
|
||||
// ------------------------------------------------------------ header --
|
||||
if !root.play-view: HorizontalLayout {
|
||||
if root.page == "music" && !root.play-view: HorizontalLayout {
|
||||
alignment: center;
|
||||
spacing: 16px;
|
||||
padding-top: 18px;
|
||||
@@ -146,7 +165,7 @@ export component AppWindow inherits Window {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- body --
|
||||
if !root.play-view && root.root-screen: ShelfView {
|
||||
if root.page == "music" && !root.play-view && root.root-screen: ShelfView {
|
||||
shelves: root.shelves;
|
||||
shelf-row: root.shelf-row;
|
||||
sel-index: root.sel-index;
|
||||
@@ -154,7 +173,7 @@ export component AppWindow inherits Window {
|
||||
enter-category(s, key) => { root.enter-category(s, key); }
|
||||
}
|
||||
|
||||
if !root.play-view && !root.root-screen: ResultsView {
|
||||
if root.page == "music" && !root.play-view && !root.root-screen: ResultsView {
|
||||
group-label: root.group-label;
|
||||
category-heading: root.category-heading;
|
||||
album-heading: root.album-heading;
|
||||
@@ -174,7 +193,39 @@ export component AppWindow inherits Window {
|
||||
play-album(id) => { root.play-album(id); }
|
||||
}
|
||||
|
||||
if root.play-view: PlayView {
|
||||
if root.page == "room": RoomView {
|
||||
rows: root.room-rows;
|
||||
scenes: root.room-scenes;
|
||||
swatches: root.room-swatches;
|
||||
presets: root.shutter-presets;
|
||||
loading: root.room-loading;
|
||||
toggle-light(id) => { root.toggle-light(id); }
|
||||
set-level(id, n) => { root.set-light-level(id, n); }
|
||||
set-swatch(id, i) => { root.set-light-swatch(id, i); }
|
||||
set-preset(id, i) => { root.set-shutter-preset(id, i); }
|
||||
shutter(id, what) => { root.shutter(id, what); }
|
||||
activate-scene(id) => { root.activate-scene(id); }
|
||||
}
|
||||
|
||||
if root.page == "typing": VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: "Tippen";
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
color: Theme.paper;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
Text {
|
||||
text: "Noch nicht portiert.";
|
||||
font-size: Theme.text-heading;
|
||||
font-weight: 700;
|
||||
color: Theme.text-dim;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
if root.page == "music" && root.play-view: PlayView {
|
||||
now: root.now;
|
||||
playing: root.playing;
|
||||
progress: root.progress;
|
||||
@@ -196,7 +247,7 @@ export component AppWindow inherits Window {
|
||||
// rather than inside the component: a plain element takes its *preferred* width
|
||||
// from its content, not its parent's width, so a bar that only declared a height
|
||||
// left gaps for the grid behind it to show through.
|
||||
if !root.play-view && root.has-bar: PlayerBar {
|
||||
if root.page == "music" && !root.play-view && root.has-bar: PlayerBar {
|
||||
x: 0;
|
||||
y: parent.height - self.height;
|
||||
width: parent.width;
|
||||
@@ -223,6 +274,41 @@ export component AppWindow inherits Window {
|
||||
clicked => { root.go-back(); }
|
||||
}
|
||||
|
||||
// The vertical tab rail. Right edge, vertically centred - the one fixed control
|
||||
// that is in the same place on every page.
|
||||
tab-rail := Rectangle {
|
||||
x: parent.width - self.width - 16px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 60px;
|
||||
height: rail.preferred-height;
|
||||
border-radius: 30px;
|
||||
background: Theme.paper.with-alpha(0.22);
|
||||
border-width: 1px;
|
||||
border-color: Theme.paper.with-alpha(0.3);
|
||||
|
||||
rail := VerticalLayout {
|
||||
padding: 8px;
|
||||
spacing: 6px;
|
||||
for tab[i] in root.tabs: Rectangle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
background: root.page == tab.id ? Theme.accent : transparent;
|
||||
animate background { duration: 150ms; }
|
||||
Text {
|
||||
text: tab.icon;
|
||||
font-size: 20px;
|
||||
color: root.page == tab.id ? white : Theme.paper;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
TouchArea {
|
||||
clicked => { root.select-page(tab.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.show-sheet: AlbumSheet {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -89,3 +89,63 @@ export struct NowPlaying {
|
||||
// "Noch 38:20 im Album" - empty for a podcast, where it means nothing.
|
||||
remaining-label: string,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Mein Zimmer --
|
||||
|
||||
// One device card on the room page. Lights and shutters share a struct rather than
|
||||
// having one each, because the grid renders them in the order Home Assistant's config
|
||||
// lists them and two models would lose that order. `kind` is what the view branches on.
|
||||
export struct RoomCard {
|
||||
entity-id: string,
|
||||
name: string,
|
||||
// "light" | "shutter"
|
||||
kind: string,
|
||||
on: bool,
|
||||
|
||||
// --- light ---
|
||||
is-color: bool,
|
||||
// 0..5, matching the five brightness buttons.
|
||||
level: int,
|
||||
tint: color,
|
||||
// Which of the six swatches the light is currently set to, or -1.
|
||||
swatch: int,
|
||||
|
||||
// --- shutter ---
|
||||
// Percent *closed*: 0 is fully open. Home Assistant reports the opposite; the
|
||||
// inversion happens once, in Rust.
|
||||
closed-percent: float,
|
||||
// "Fährt runter …", or the preset name the position matches.
|
||||
status: string,
|
||||
// "" | "up" | "down"
|
||||
moving: string,
|
||||
supports-position: bool,
|
||||
// Which of the four presets the position matches, or -1.
|
||||
preset: int,
|
||||
}
|
||||
|
||||
// The room grid, pre-chunked into rows for the same reason the album grid is: the
|
||||
// view lays rows out, and how many fit is a function of the window width, which Rust
|
||||
// is the one that knows.
|
||||
export struct RoomRow {
|
||||
cards: [RoomCard],
|
||||
}
|
||||
|
||||
export struct SceneTile {
|
||||
entity-id: string,
|
||||
name: string,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
// One of the six colours a colour-capable light can be set to.
|
||||
export struct Swatch {
|
||||
name: string,
|
||||
tint: color,
|
||||
}
|
||||
|
||||
// One entry in the vertical tab rail. Built in Rust like every other string on
|
||||
// screen, so the page order, the icons and the German labels live in one place.
|
||||
export struct Tab {
|
||||
id: string,
|
||||
icon: string,
|
||||
label: string,
|
||||
}
|
||||
|
||||
460
slint-frontend/ui/room.slint
Normal file
460
slint-frontend/ui/room.slint
Normal file
@@ -0,0 +1,460 @@
|
||||
// "Mein Zimmer" - the Home Assistant room panel.
|
||||
//
|
||||
// Its own violet hue rather than the player's teal, so the two pages read as siblings.
|
||||
// Lights and shutters are drawn from one `RoomCard` model in Home Assistant's own
|
||||
// config order; `kind` is what each card branches on.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { RoomCard, RoomRow, SceneTile, Swatch } from "models.slint";
|
||||
|
||||
component ToggleSwitch inherits Rectangle {
|
||||
in property <bool> on;
|
||||
callback clicked();
|
||||
|
||||
width: 74px;
|
||||
height: 42px;
|
||||
border-radius: 21px;
|
||||
background: root.on ? Theme.room-accent : Theme.room-toggle-off;
|
||||
animate background { duration: 140ms; }
|
||||
|
||||
Rectangle {
|
||||
x: root.on ? parent.width - self.width - 4px : 4px;
|
||||
y: 4px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 17px;
|
||||
background: white;
|
||||
drop-shadow-color: #0f051d66;
|
||||
drop-shadow-blur: 6px;
|
||||
drop-shadow-offset-y: 2px;
|
||||
animate x { duration: 140ms; easing: ease-out; }
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
}
|
||||
|
||||
// A flat labelled button - the shutter presets and the up/stop/down row.
|
||||
component RoomButton inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> active;
|
||||
in property <brush> active-fill: Theme.room-accent;
|
||||
in property <length> text-size: 14px;
|
||||
callback clicked();
|
||||
|
||||
border-radius: 14px;
|
||||
background: root.active ? root.active-fill : (touch.pressed ? Theme.room-off-bg : Theme.room-btn);
|
||||
animate background { duration: 120ms; }
|
||||
|
||||
Text {
|
||||
text: root.label;
|
||||
font-size: root.text-size;
|
||||
font-weight: 800;
|
||||
color: root.active ? white : Theme.room-btn-fg;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
}
|
||||
|
||||
component RoomCardFrame inherits Rectangle {
|
||||
in property <bool> glow;
|
||||
in property <color> glow-tint;
|
||||
|
||||
background: Theme.room-card.with-alpha(0.55);
|
||||
border-radius: 22px;
|
||||
// Standing in for the web card's `0 0 0 3px color-mix(...)` ring: a lit lamp's own
|
||||
// colour around its card is how the page reads at a glance from across the room.
|
||||
border-width: root.glow ? 3px : 0px;
|
||||
border-color: root.glow-tint;
|
||||
drop-shadow-color: #0f051d59;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-offset-y: 8px;
|
||||
}
|
||||
|
||||
component LightCard inherits RoomCardFrame {
|
||||
in property <RoomCard> card;
|
||||
in property <[Swatch]> swatches;
|
||||
callback toggle();
|
||||
callback set-level(int);
|
||||
callback set-swatch(int);
|
||||
|
||||
glow: root.card.on;
|
||||
glow-tint: root.card.tint;
|
||||
|
||||
VerticalLayout {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-top: 18px;
|
||||
padding-bottom: 20px;
|
||||
spacing: 14px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 14px;
|
||||
Rectangle {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: root.card.on ? root.card.tint : Theme.room-off-bg;
|
||||
drop-shadow-color: root.card.on ? root.card.tint : transparent;
|
||||
drop-shadow-blur: 22px;
|
||||
// A pendant for a colour lamp, a ceiling fitting for a plain one -
|
||||
// drawn rather than iconographic, because there is no icon font here.
|
||||
Rectangle {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
x: 11px;
|
||||
y: 11px;
|
||||
if root.card.is-color: Path {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
viewbox-width: 48;
|
||||
viewbox-height: 48;
|
||||
stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg;
|
||||
stroke-width: 3px;
|
||||
commands: "M 24 7 A 17 17 0 1 1 23.9 7 Z";
|
||||
}
|
||||
if !root.card.is-color: Path {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
viewbox-width: 48;
|
||||
viewbox-height: 48;
|
||||
stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg;
|
||||
stroke-width: 3px;
|
||||
commands: "M 9 30 L 24 11 L 39 30 Z M 24 4 L 24 11";
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: root.card.name;
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
color: Theme.room-ink;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToggleSwitch {
|
||||
on: root.card.on;
|
||||
clicked => { root.toggle(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Five steps rather than a slider: a child aiming at a bar hits a level.
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
height: 46px;
|
||||
opacity: root.card.on ? 1.0 : 0.55;
|
||||
for step[i] in [1, 2, 3, 4, 5]: Rectangle {
|
||||
border-radius: 12px;
|
||||
background: root.card.on && root.card.level >= step
|
||||
? root.card.tint
|
||||
: Theme.room-off-bg;
|
||||
animate background { duration: 120ms; }
|
||||
TouchArea {
|
||||
clicked => { root.set-level(step); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.card.is-color: HorizontalLayout {
|
||||
spacing: 10px;
|
||||
height: 46px;
|
||||
alignment: start;
|
||||
for swatch[i] in root.swatches: Rectangle {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 23px;
|
||||
background: swatch.tint;
|
||||
border-width: 4px;
|
||||
border-color: root.card.on && root.card.swatch == i
|
||||
? Theme.room-btn-fg
|
||||
: Theme.room-card.with-alpha(0.8);
|
||||
drop-shadow-color: #0f051d59;
|
||||
drop-shadow-blur: 8px;
|
||||
drop-shadow-offset-y: 3px;
|
||||
TouchArea {
|
||||
clicked => { root.set-swatch(i); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component ShutterCard inherits RoomCardFrame {
|
||||
in property <RoomCard> card;
|
||||
in property <[string]> presets;
|
||||
callback set-preset(int);
|
||||
callback open();
|
||||
callback stop();
|
||||
callback close();
|
||||
|
||||
VerticalLayout {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-top: 18px;
|
||||
padding-bottom: 20px;
|
||||
spacing: 16px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 14px;
|
||||
alignment: start;
|
||||
Rectangle {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: Theme.room-icon-bg;
|
||||
Path {
|
||||
x: 11px;
|
||||
y: 11px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
viewbox-width: 48;
|
||||
viewbox-height: 48;
|
||||
stroke: Theme.room-icon-fg;
|
||||
stroke-width: 3px;
|
||||
commands: "M 7 7 L 41 7 L 41 41 L 7 41 Z M 7 16 L 41 16 M 7 24 L 41 24 M 7 32 L 41 32";
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: root.card.name;
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
color: Theme.room-ink;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: root.card.status;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: Theme.room-sub.with-alpha(0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 18px;
|
||||
|
||||
// The window itself: sky behind, slats drawn down from the top to however
|
||||
// closed the shutter is. Real covers report their own position, so this
|
||||
// only ever reflects Home Assistant - no client-side movement animation.
|
||||
Rectangle {
|
||||
width: 104px;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
border-width: 3px;
|
||||
border-color: Theme.room-icon-fg;
|
||||
clip: true;
|
||||
background: @linear-gradient(180deg, Theme.room-window-a 0%, Theme.room-window-b 100%);
|
||||
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: 100%;
|
||||
height: parent.height * clamp(root.card.closed-percent / 100, 0.0, 1.0);
|
||||
clip: true;
|
||||
animate height { duration: 200ms; easing: linear; }
|
||||
// The slats, as a fixed ladder clipped by the parent's height.
|
||||
for slat[i] in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]: Rectangle {
|
||||
y: i * 12px;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: Theme.room-slat-a;
|
||||
Rectangle {
|
||||
y: 9px;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: Theme.room-slat-b;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 10px;
|
||||
horizontal-stretch: 1;
|
||||
|
||||
if root.card.supports-position: VerticalLayout {
|
||||
spacing: 8px;
|
||||
for row[r] in [0, 1]: HorizontalLayout {
|
||||
spacing: 8px;
|
||||
height: 48px;
|
||||
for col[c] in [0, 1]: RoomButton {
|
||||
label: root.presets[r * 2 + c];
|
||||
active: root.card.preset == r * 2 + c;
|
||||
clicked => { root.set-preset(r * 2 + c); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
height: 52px;
|
||||
RoomButton {
|
||||
label: "▲";
|
||||
text-size: 20px;
|
||||
active: root.card.moving == "up";
|
||||
clicked => { root.open(); }
|
||||
}
|
||||
RoomButton {
|
||||
label: "■";
|
||||
text-size: 18px;
|
||||
active: true;
|
||||
active-fill: Theme.room-stop;
|
||||
clicked => { root.stop(); }
|
||||
}
|
||||
RoomButton {
|
||||
label: "▼";
|
||||
text-size: 20px;
|
||||
active: root.card.moving == "down";
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component RoomView inherits Rectangle {
|
||||
in property <[RoomRow]> rows;
|
||||
in property <[SceneTile]> scenes;
|
||||
in property <[Swatch]> swatches;
|
||||
in property <[string]> presets;
|
||||
in property <bool> loading;
|
||||
callback toggle-light(string);
|
||||
callback set-level(string, int);
|
||||
callback set-swatch(string, int);
|
||||
callback set-preset(string, int);
|
||||
callback shutter(string, string); // entity, "open" | "stop" | "close"
|
||||
callback activate-scene(string);
|
||||
|
||||
background: @linear-gradient(180deg,
|
||||
Theme.room-top 0%,
|
||||
Theme.room-mid 45%,
|
||||
Theme.room-deep 100%);
|
||||
|
||||
VerticalLayout {
|
||||
padding-top: 18px;
|
||||
spacing: 6px;
|
||||
|
||||
Text {
|
||||
text: "Mein Zimmer";
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
color: Theme.room-paper;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
if root.loading: VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: "Einen Moment …";
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: Theme.room-paper;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
if !root.loading: Flickable {
|
||||
content-height: body.preferred-height;
|
||||
content-width: self.width;
|
||||
|
||||
body := VerticalLayout {
|
||||
padding-left: 32px;
|
||||
padding-right: Theme.rail-clearance;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 64px;
|
||||
spacing: 30px;
|
||||
alignment: start;
|
||||
|
||||
if root.scenes.length > 0: VerticalLayout {
|
||||
spacing: 14px;
|
||||
Text {
|
||||
text: "Szenen";
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
color: Theme.room-paper;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
for scene in root.scenes: Rectangle {
|
||||
height: 52px;
|
||||
width: pill.preferred-width;
|
||||
border-radius: 26px;
|
||||
background: scene.active
|
||||
? Theme.room-paper
|
||||
: Theme.room-paper.with-alpha(0.18);
|
||||
animate background { duration: 140ms; }
|
||||
pill := HorizontalLayout {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: "✨";
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: scene.name;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: scene.active ? Theme.room-btn-fg : Theme.room-paper;
|
||||
}
|
||||
}
|
||||
}
|
||||
TouchArea {
|
||||
clicked => { root.activate-scene(scene.entity-id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both kinds share one cell width, so the grid stays a grid whatever
|
||||
// mix of devices Home Assistant reports.
|
||||
for row in root.rows: HorizontalLayout {
|
||||
alignment: center;
|
||||
spacing: 20px;
|
||||
for card in row.cards: Rectangle {
|
||||
width: Theme.room-card-width;
|
||||
height: card.kind == "shutter" ? 268px : 246px;
|
||||
if card.kind == "light": LightCard {
|
||||
width: 100%;
|
||||
card: card;
|
||||
swatches: root.swatches;
|
||||
toggle => { root.toggle-light(card.entity-id); }
|
||||
set-level(n) => { root.set-level(card.entity-id, n); }
|
||||
set-swatch(i) => { root.set-swatch(card.entity-id, i); }
|
||||
}
|
||||
if card.kind == "shutter": ShutterCard {
|
||||
width: 100%;
|
||||
card: card;
|
||||
presets: root.presets;
|
||||
set-preset(i) => { root.set-preset(card.entity-id, i); }
|
||||
open => { root.shutter(card.entity-id, "open"); }
|
||||
stop => { root.shutter(card.entity-id, "stop"); }
|
||||
close => { root.shutter(card.entity-id, "close"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,32 @@ export global Theme {
|
||||
out property <color> row-idle: #f7fcfd1a;
|
||||
out property <color> row-active: #f7fcfd38;
|
||||
|
||||
// --------------------------------------------------- Mein Zimmer (violet) --
|
||||
// Its own hue (~298-310) rather than the player's teal (~210), so the two pages
|
||||
// read as siblings rather than one bleeding into the other. Same oklch-converted-
|
||||
// once treatment as above.
|
||||
out property <color> room-ink: #181322; // oklch(20% 0.03 300)
|
||||
out property <color> room-paper: #f6f4fb; // oklch(97% 0.01 302)
|
||||
out property <color> room-accent: #986dd0; // oklch(62% 0.15 302)
|
||||
out property <color> room-card: #f3f0f9; // oklch(96% 0.012 300)
|
||||
out property <color> room-top: #885cb5; // oklch(56% 0.14 305)
|
||||
out property <color> room-mid: #4d2f77; // oklch(38% 0.12 300)
|
||||
out property <color> room-deep: #1e0c37; // oklch(21% 0.08 298)
|
||||
out property <color> room-icon-bg: #dad3e9; // oklch(88% 0.03 300)
|
||||
out property <color> room-icon-fg: #3e3451; // oklch(35% 0.05 300)
|
||||
out property <color> room-off-bg: #d9d5e3; // oklch(88% 0.02 300)
|
||||
out property <color> room-off-fg: #736f7c; // oklch(55% 0.02 300)
|
||||
out property <color> room-btn: #dcd8e6; // oklch(89% 0.02 300)
|
||||
out property <color> room-btn-fg: #2c243a; // oklch(28% 0.04 300)
|
||||
out property <color> room-stop: #e85854; // oklch(65% 0.18 25)
|
||||
out property <color> room-toggle-off: #cbc9d0;
|
||||
out property <color> room-sub: #585264; // oklch(45% 0.03 300)
|
||||
out property <color> room-slat-a: #a19baf; // oklch(70% 0.03 300)
|
||||
out property <color> room-slat-b: #7d778a; // oklch(58% 0.03 300)
|
||||
out property <color> room-window-a: #87d1e8; // oklch(82% 0.08 220)
|
||||
out property <color> room-window-b: #50afb4; // oklch(70% 0.09 200)
|
||||
out property <length> room-card-width: 380px;
|
||||
|
||||
// ------------------------------------------------------------------- metrics --
|
||||
out property <length> radius-panel: 22px;
|
||||
out property <length> radius-card: 16px;
|
||||
|
||||
Reference in New Issue
Block a user