diff --git a/slint-frontend/README.md b/slint-frontend/README.md index 87d81cf..2b4d82d 100644 --- a/slint-frontend/README.md +++ b/slint-frontend/README.md @@ -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. diff --git a/slint-frontend/src/api.rs b/slint-frontend/src/api.rs index 7120a4e..8a1c141 100644 --- a/slint-frontend/src/api.rs +++ b/slint-frontend/src/api.rs @@ -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(&self, path: &str) -> Result { + 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, Error> { let out: LibraryOut = self .agent diff --git a/slint-frontend/src/ha.rs b/slint-frontend/src/ha.rs new file mode 100644 index 0000000..654b9b7 --- /dev/null +++ b/slint-frontend/src/ha.rs @@ -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, +} + +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, + #[serde(default)] + pub scenes: Vec, +} + +/// 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, +} + +impl EntityState { + pub fn is_on(&self) -> bool { + self.state == "on" + } + + fn number(&self, key: &str) -> Option { + 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 { + 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, Error> { + match self.get_json::("/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 { + 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, + /// 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), + 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, + 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"); + } +} diff --git a/slint-frontend/src/keymap.rs b/slint-frontend/src/keymap.rs index ffd8748..2e2d5cc 100644 --- a/slint-frontend/src/keymap.rs +++ b/slint-frontend/src/keymap.rs @@ -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(); diff --git a/slint-frontend/src/main.rs b/slint-frontend/src/main.rs index bbb8477..9e0c882 100644 --- a/slint-frontend/src/main.rs +++ b/slint-frontend/src/main.rs @@ -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, String>), Cover(covers::Ready), Ws(WsEvent), + /// `None` means Home Assistant is not configured, which hides the room tab. + HaConfig(Option), + 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_config: Option, + ha_states: HashMap, + /// 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, + 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, } 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 { + 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) { 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 = [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 = 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 = 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> { - 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 { 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> { let (to_ui, from_workers) = channel::(); let (cover_jobs, cover_rx) = channel::(); let (commands, command_rx) = channel::(); + let (ha_jobs, ha_rx) = channel::(); // Covers: fetch, downscale, cache to disk. Never on the UI thread. { @@ -711,6 +889,24 @@ fn main() -> Result<(), Box> { })?; } + // 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> { 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> { 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::>(), + ))); + window.set_shutter_presets(ModelRc::new(VecModel::from( + ha::SHUTTER_PRESETS + .iter() + .map(|(label, _)| SharedString::from(*label)) + .collect::>(), + ))); + 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>, 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 { diff --git a/slint-frontend/ui/app.slint b/slint-frontend/ui/app.slint index 9d7f2ea..4949ac2 100644 --- a/slint-frontend/ui/app.slint +++ b/slint-frontend/ui/app.slint @@ -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 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 room-loading: true; + in property loading: true; in property splash-text; in property 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%; diff --git a/slint-frontend/ui/models.slint b/slint-frontend/ui/models.slint index 2a3ff21..25002a0 100644 --- a/slint-frontend/ui/models.slint +++ b/slint-frontend/ui/models.slint @@ -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, +} diff --git a/slint-frontend/ui/room.slint b/slint-frontend/ui/room.slint new file mode 100644 index 0000000..1ca4d6e --- /dev/null +++ b/slint-frontend/ui/room.slint @@ -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 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 label; + in property active; + in property active-fill: Theme.room-accent; + in property 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 glow; + in property 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 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 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 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"); } + } + } + } + } + } + } +} diff --git a/slint-frontend/ui/theme.slint b/slint-frontend/ui/theme.slint index db13197..b116fda 100644 --- a/slint-frontend/ui/theme.slint +++ b/slint-frontend/ui/theme.slint @@ -48,6 +48,32 @@ export global Theme { out property row-idle: #f7fcfd1a; out property 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 room-ink: #181322; // oklch(20% 0.03 300) + out property room-paper: #f6f4fb; // oklch(97% 0.01 302) + out property room-accent: #986dd0; // oklch(62% 0.15 302) + out property room-card: #f3f0f9; // oklch(96% 0.012 300) + out property room-top: #885cb5; // oklch(56% 0.14 305) + out property room-mid: #4d2f77; // oklch(38% 0.12 300) + out property room-deep: #1e0c37; // oklch(21% 0.08 298) + out property room-icon-bg: #dad3e9; // oklch(88% 0.03 300) + out property room-icon-fg: #3e3451; // oklch(35% 0.05 300) + out property room-off-bg: #d9d5e3; // oklch(88% 0.02 300) + out property room-off-fg: #736f7c; // oklch(55% 0.02 300) + out property room-btn: #dcd8e6; // oklch(89% 0.02 300) + out property room-btn-fg: #2c243a; // oklch(28% 0.04 300) + out property room-stop: #e85854; // oklch(65% 0.18 25) + out property room-toggle-off: #cbc9d0; + out property room-sub: #585264; // oklch(45% 0.03 300) + out property room-slat-a: #a19baf; // oklch(70% 0.03 300) + out property room-slat-b: #7d778a; // oklch(58% 0.03 300) + out property room-window-a: #87d1e8; // oklch(82% 0.08 220) + out property room-window-b: #50afb4; // oklch(70% 0.09 200) + out property room-card-width: 380px; + // ------------------------------------------------------------------- metrics -- out property radius-panel: 22px; out property radius-card: 16px;