From c8e6fcb65956325ba57166329ee28e9661255669 Mon Sep 17 00:00:00 2001 From: Kaloyan Danchev Date: Sat, 24 Oct 2026 13:48:10 +0300 Subject: [PATCH] Redesign HA cards with fill-based backgrounds and add media player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation: arrow keys now always navigate the card grid (removed left/right value adjustment from Range/Climate cards). Scroll wheel on the Magic Remote adjusts range values instead. Cards: 4-column layout with taller (160px) cards. Each card shows a fill bar proportional to its value — brightness for lights, target temperature for climate, volume for media. State-based accent colors (warm yellow for lights, orange/red for heating, blue for cooling, purple for media playback). New card type: Media (media_player) — shows artist, track title, volume fill, and album art placeholder. OK toggles play/pause, scroll wheel adjusts volume. Co-Authored-By: Claude Opus 4.6 --- ha-home/config.json | 3 +- src/ha_client.rs | 4 + src/main.rs | 102 +++++++--- src/state.rs | 72 ++++--- src/view.rs | 454 ++++++++++++++++++++++++++++++++++++-------- 5 files changed, 498 insertions(+), 137 deletions(-) diff --git a/ha-home/config.json b/ha-home/config.json index 61abad7..1e17c17 100644 --- a/ha-home/config.json +++ b/ha-home/config.json @@ -40,7 +40,8 @@ { "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" }, { "type": "toggle", "entity_id": "light.picture_frame_lamp", "name": "Picture Lamp" }, { "type": "toggle", "entity_id": "cover.living_room_curtain", "name": "Curtain" }, - { "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" } + { "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" }, + { "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" } ] }, { diff --git a/src/ha_client.rs b/src/ha_client.rs index 577c3a8..90a8445 100644 --- a/src/ha_client.rs +++ b/src/ha_client.rs @@ -440,6 +440,10 @@ pub fn entity_ids_from_config(config: &Value) -> Vec { } } + if let Some(media) = config.get("media").and_then(|m| m.get("entity_id")).and_then(Value::as_str) { + ids.push(media.to_owned()); + } + if let Some(rooms) = config.get("rooms").and_then(Value::as_array) { for room in rooms { if let Some(sensors) = room.get("sensors").and_then(Value::as_array) { diff --git a/src/main.rs b/src/main.rs index bf03ede..56bbad4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -327,6 +327,42 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) { }), }); } + HaEffect::MediaPlayPause(entity_id) => { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "media_player".into(), + service: "media_play_pause".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({}), + }); + } + HaEffect::MediaNext(entity_id) => { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "media_player".into(), + service: "media_next_track".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({}), + }); + } + HaEffect::MediaPrev(entity_id) => { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "media_player".into(), + service: "media_previous_track".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({}), + }); + } + HaEffect::SetVolume(entity_id, volume) => { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "media_player".into(), + service: "volume_set".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({ "volume_level": volume }), + }); + } } } @@ -1027,34 +1063,46 @@ fn run() -> Result<(), String> { mouse_y, .. } if state.overlay.is_none() => { - let over_favorites = state.page == state::Page::Home - && pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| { - (64. ..1856.).contains(&x) - && scene.controls.iter().any(|control| { - matches!(control.target, Target::App(_)) - && control.rect.contains(x, y) - }) - }); - if state.page == state::Page::Home && (x != 0 || over_favorites) { - state.scroll_favorites( - (if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH, - ); - press = None; + // HA card scroll: adjust range/climate/media values + let ha_scroll = state.page == state::Page::Ha + && matches!(&state.focus, state::Target::HaCard(room, index) + if matches!( + state.ha.config.rooms[*room].cards[*index].card_type, + state::HaCardType::Range | state::HaCardType::Climate | state::HaCardType::Media + )) + && y != 0; + if ha_scroll { + pending = state.scroll_ha(y); } else { - let max = scene - .controls - .iter() - .filter(|c| c.scrollable) - .map(|c| { - c.rect.y + c.rect.h + state.scroll - - if state.page == state::Page::Home { - 1041.6 - } else { - 982.4 - } - }) - .fold(0., f32::max); - state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.)); + let over_favorites = state.page == state::Page::Home + && pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| { + (64. ..1856.).contains(&x) + && scene.controls.iter().any(|control| { + matches!(control.target, Target::App(_)) + && control.rect.contains(x, y) + }) + }); + if state.page == state::Page::Home && (x != 0 || over_favorites) { + state.scroll_favorites( + (if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH, + ); + press = None; + } else { + let max = scene + .controls + .iter() + .filter(|c| c.scrollable) + .map(|c| { + c.rect.y + c.rect.h + state.scroll + - if state.page == state::Page::Home { + 1041.6 + } else { + 982.4 + } + }) + .fold(0., f32::max); + state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.)); + } } state.pointer = true; state.dirty = true; diff --git a/src/state.rs b/src/state.rs index d02e81a..5419b9e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -161,6 +161,10 @@ pub enum HaEffect { segments: Vec, mode: Option, }, + MediaPlayPause(String), + MediaNext(String), + MediaPrev(String), + SetVolume(String, f32), } pub const KEYS: [&str; 40] = [ @@ -176,6 +180,7 @@ pub enum HaCardType { Climate, Action, Vacuum, + Media, } #[derive(Clone, Debug)] @@ -284,6 +289,7 @@ impl HaTabConfig { Some("climate") => HaCardType::Climate, Some("action") => HaCardType::Action, Some("vacuum") => HaCardType::Vacuum, + Some("media") => HaCardType::Media, _ => continue, }; let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned(); @@ -830,34 +836,15 @@ impl State { } if let Target::HaCard(room, index) = self.focus { let room_cfg = &self.ha.config.rooms[room]; - let card = &room_cfg.cards[index]; let cols = self.ha_card_columns(); - match (&card.card_type, direction) { - (HaCardType::Range, Direction::Left) => { - let brightness = self.ha_entity_brightness(&card.entity_id); - let new = (brightness - 25.5).max(0.); - return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))]; - } - (HaCardType::Range, Direction::Right) => { - let brightness = self.ha_entity_brightness(&card.entity_id); - let new = (brightness + 25.5).min(255.); - return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))]; - } - (HaCardType::Climate, Direction::Left) => { - let temp = self.ha_entity_target_temp(&card.entity_id); - return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp - 0.5))]; - } - (HaCardType::Climate, Direction::Right) => { - let temp = self.ha_entity_target_temp(&card.entity_id); - return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp + 0.5))]; - } - (_, Direction::Left) if index % cols > 0 => { + match direction { + Direction::Left if index % cols > 0 => { self.focus(Target::HaCard(room, index - 1), controls); } - (_, Direction::Right) if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => { + Direction::Right if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => { self.focus(Target::HaCard(room, index + 1), controls); } - (_, Direction::Up) => { + Direction::Up => { if index >= cols { self.focus(Target::HaCard(room, index - cols), controls); } else if self.ha.config.weather.is_some() { @@ -866,7 +853,7 @@ impl State { self.focus(Target::HaRoom(self.ha.room), controls); } } - (_, Direction::Down) => { + Direction::Down => { let next = index + cols; if next < room_cfg.cards.len() { self.focus(Target::HaCard(room, next), controls); @@ -1225,7 +1212,7 @@ impl State { } fn ha_card_columns(&self) -> usize { - 3 + 4 } fn ha_entity_brightness(&self, entity_id: &str) -> f32 { @@ -1242,6 +1229,38 @@ impl State { .unwrap_or(20.) as f32 } + fn ha_entity_volume(&self, entity_id: &str) -> f32 { + self.ha.entities.get(entity_id) + .and_then(|e| e.attributes.get("volume_level")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.) as f32 + } + + pub fn scroll_ha(&mut self, delta: i32) -> Vec { + if let Target::HaCard(room, index) = &self.focus { + let card = &self.ha.config.rooms[*room].cards[*index]; + match card.card_type { + HaCardType::Range => { + let brightness = self.ha_entity_brightness(&card.entity_id); + let new = (brightness + delta as f32 * 25.5).clamp(0., 255.); + return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))]; + } + HaCardType::Climate => { + let temp = self.ha_entity_target_temp(&card.entity_id); + let new = temp + delta as f32 * 0.5; + return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), new))]; + } + HaCardType::Media => { + let vol = self.ha_entity_volume(&card.entity_id); + let new = (vol + delta as f32 * 0.05).clamp(0., 1.); + return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))]; + } + _ => {} + } + } + vec![] + } + fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec { let vac = match &self.ha.config.vacuum { Some(v) => v, @@ -1670,6 +1689,9 @@ impl State { self.focus = Target::HaVacuumRoom(0); } } + HaCardType::Media => { + return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))]; + } _ => {} } } diff --git a/src/view.rs b/src/view.rs index f9d08d8..4f20a49 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1677,10 +1677,10 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { } // ── Card grid ──────────────────────────────────────────────── - let cols = 3usize; - let card_w = 564.; + let cols = 4usize; let card_gap = 16.; - let card_h = 88.; + let card_w = (1752. - (cols - 1) as f32 * card_gap) / cols as f32; + let card_h = 160.; for (index, card) in room.cards.iter().enumerate() { let col = index % cols; @@ -1707,6 +1707,9 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { HaCardType::Vacuum => { ha_vacuum_card(out, state, assets, rect, card, entity, focused); } + HaCardType::Media => { + ha_media_card(out, state, assets, rect, card, entity, focused); + } } out.control(Target::HaCard(state.ha.room, index), rect, true); } @@ -1752,6 +1755,7 @@ fn ha_entity_icon(card_type: &crate::state::HaCardType, entity_id: &str) -> &'st HaCardType::Climate => "thermometer", HaCardType::Action => "scene", HaCardType::Vacuum => "vacuum", + HaCardType::Media => "spotify", } } @@ -1760,10 +1764,55 @@ fn ha_state_color(entity: Option<&crate::state::HaEntity>) -> Color { Some("on") => c(0x7EB8DA, 1.), Some("heat" | "heat_cool" | "auto") => c(0xf0a060, 1.), Some("cool") => c(0x60b0f0, 1.), + Some("playing") => c(0x9b7ddb, 1.), + Some("paused") => c(0x7d9bdb, 0.7), _ => c(0xbbbbbb, 0.5), } } +fn ha_fill_color(card_type: &crate::state::HaCardType, entity: Option<&crate::state::HaEntity>) -> Color { + use crate::state::HaCardType; + match card_type { + HaCardType::Range => c(0xfab95c, 0.30), + HaCardType::Climate => { + match entity.map(|e| e.state.as_str()) { + Some("heat" | "heat_cool" | "auto") => c(0xf06040, 0.28), + Some("cool") => c(0x4090f0, 0.28), + _ => c(0xf0a060, 0.15), + } + } + HaCardType::Toggle => { + let is_on = entity.is_some_and(|e| e.state == "on" || e.state == "open"); + if is_on { + if entity.is_some_and(|e| e.attributes.get("device_class").and_then(|v| v.as_str()) == Some("lock")) { + c(0x50b860, 0.28) + } else { + c(0xfab95c, 0.25) + } + } else { + c(0, 0.) + } + } + HaCardType::Media => c(0x8060c0, 0.25), + HaCardType::Vacuum => { + match entity.map(|e| e.state.as_str()) { + Some("cleaning") => c(0x7EB8DA, 0.25), + Some("returning") => c(0xf0a060, 0.20), + _ => c(0, 0.), + } + } + _ => c(0, 0.), + } +} + +fn ha_card_fill(out: &mut Scene, rect: Rect, fill_pct: f32, color: Color) { + if fill_pct <= 0.01 || color[3] < 0.01 { return; } + let pct = fill_pct.clamp(0., 1.); + let fill_w = (rect.w * pct).max(44.); + let radius = 22f32.min(fill_w / 2.); + out.shape(Rect::new(rect.x, rect.y, fill_w, rect.h), radius, color); +} + // ── Toggle card ────────────────────────────────────────────────── fn ha_toggle_card( @@ -1778,37 +1827,67 @@ fn ha_toggle_card( ha_glass(out, rect, focused); let icon_name = ha_entity_icon(&card.card_type, &card.entity_id); let accent = ha_state_color(entity); - let is_on = entity.is_some_and(|e| e.state == "on"); + let is_on = entity.is_some_and(|e| e.state == "on" || e.state == "open"); + let fill_color = ha_fill_color(&card.card_type, entity); + if is_on { + ha_card_fill(out, rect, 1., fill_color); + } + + // Icon in tinted circle + let icon_bg = if is_on { [accent[0], accent[1], accent[2], 0.2] } else { c(0xffffff, 0.08) }; + out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg); out.icon( assets, icon_name, - Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + Rect::new(rect.x + 24., rect.y + 24., 32., 32.), if is_on { accent } else { c(0xbbbbbb, 0.5) }, ); + + // Type label (small) + let type_label = if card.entity_id.starts_with("light.") { + "Light" + } else if card.entity_id.starts_with("cover.") { + "Cover" + } else if card.entity_id.starts_with("switch.") { + "Switch" + } else { + "Toggle" + }; + out.text( + type_label, + Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.), + text(12., 400, 0.04), + if is_on { accent } else { c(0xe8e8e8, 0.45) }, + false, + ); + // Entity name out.text( &card.name, - Rect::new(rect.x + 80., rect.y + 22., rect.w - 180., 24.), - text(20., 500, -0.02), + Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), + text(18., 600, -0.02), WHITE, false, ); - let status = entity.map_or("unavailable", |e| e.state.as_str()); + + // Status at bottom left + let status_text = if is_on { "On" } else { entity.map_or("Unavailable", |e| e.state.as_str()) }; + let status_text = match status_text { + "off" => "Off", + "on" => "On", + "open" => "Open", + "closed" => "Closed", + "locked" => "Locked", + "unlocked" => "Unlocked", + other => other, + }; out.text( - status, - Rect::new(rect.x + 80., rect.y + 52., rect.w - 180., 16.), - text(13.12, 400, 0.), - c(0xe8e8e8, 0.48), + status_text, + Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.), + text(22., 700, -0.02), + if is_on { WHITE } else { c(0xbbbbbb, 0.5) }, false, ); - // On/off indicator dot - let dot_x = rect.x + rect.w - 36.; - let dot_y = rect.y + rect.h / 2. - 8.; - out.shape( - Rect::new(dot_x, dot_y, 16., 16.), - 8., - if is_on { accent } else { c(0x555555, 0.6) }, - ); } // ── Range card (dimmable lights, cover position) ───────────────── @@ -1829,44 +1908,60 @@ fn ha_range_card( .and_then(|v| v.as_f64()) .unwrap_or(0.); let pct = (brightness / 255. * 100.).round() as u8; - let accent = if is_on { c(0x7EB8DA, 1.) } else { c(0xbbbbbb, 0.5) }; + let accent = if is_on { c(0xfab95c, 1.) } else { c(0xbbbbbb, 0.5) }; + // Fill background proportional to brightness + if is_on && pct > 0 { + let fill_color = ha_fill_color(&card.card_type, entity); + ha_card_fill(out, rect, pct as f32 / 100., fill_color); + } + + // Icon in tinted circle + let icon_bg = if is_on { [accent[0], accent[1], accent[2], 0.2] } else { c(0xffffff, 0.08) }; + out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg); out.icon( assets, "light", - Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + Rect::new(rect.x + 24., rect.y + 24., 32., 32.), accent, ); + + // Type label + out.text( + "Light", + Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.), + text(12., 400, 0.04), + if is_on { accent } else { c(0xe8e8e8, 0.45) }, + false, + ); + // Entity name out.text( &card.name, - Rect::new(rect.x + 80., rect.y + 16., rect.w - 180., 24.), - text(20., 500, -0.02), + Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), + text(18., 600, -0.02), WHITE, false, ); - // Brightness bar - let bar_x = rect.x + 80.; - let bar_y = rect.y + 52.; - let bar_w = rect.w - 160.; - let bar_h = 8.; - out.shape(Rect::new(bar_x, bar_y, bar_w, bar_h), 4., c(0xffffff, 0.08)); - if is_on && pct > 0 { - let fill_w = bar_w * (pct as f32 / 100.).min(1.); - out.shape(Rect::new(bar_x, bar_y, fill_w, bar_h), 4., accent); - } - // Percentage label + + // Value at bottom left let label = if is_on { format!("{}%", pct) } else { "Off".into() }; out.text( &label, - Rect::new(rect.x + rect.w - 72., rect.y + 34., 52., 20.), - text(15.2, 500, 0.).right(), + Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.), + text(22., 700, -0.02), if is_on { WHITE } else { c(0xbbbbbb, 0.5) }, false, ); - if focused { - // D-pad hint arrows - out.icon(assets, "left", Rect::new(rect.x + 80., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4)); - out.icon(assets, "right", Rect::new(rect.x + 96., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4)); + + // Scroll hint when focused + if focused && is_on { + out.text( + "scroll", + Rect::new(rect.x + rect.w - 68., rect.y + rect.h - 28., 52., 14.), + text(11., 400, 0.03).right(), + c(0xeeeeee, 0.35), + false, + ); } } @@ -1892,53 +1987,89 @@ fn ha_climate_card( .and_then(|v| v.as_f64()) .unwrap_or(0.); let hvac_mode = entity.map_or("off", |e| e.state.as_str()); + let is_active = hvac_mode != "off" && hvac_mode != "unavailable"; + // Fill proportional to target temp (15-35°C range) + if is_active { + let fill_pct = ((target_temp - 15.) / 20.).clamp(0., 1.) as f32; + let fill_color = ha_fill_color(&card.card_type, entity); + ha_card_fill(out, rect, fill_pct, fill_color); + } + + // Icon in tinted circle + let icon_bg = if is_active { [accent[0], accent[1], accent[2], 0.2] } else { c(0xffffff, 0.08) }; + out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg); out.icon( assets, "thermometer", - Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + Rect::new(rect.x + 24., rect.y + 24., 32., 32.), accent, ); + + // Mode label + let mode_label = match hvac_mode { + "heat" => "Heat", + "cool" => "Cool", + "heat_cool" | "auto" => "Auto", + "off" => "Off", + other => other, + }; + out.text( + mode_label, + Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.), + text(12., 400, 0.04), + if is_active { accent } else { c(0xe8e8e8, 0.45) }, + false, + ); + // Entity name out.text( &card.name, - Rect::new(rect.x + 80., rect.y + 16., 280., 24.), - text(20., 500, -0.02), + Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), + text(18., 600, -0.02), WHITE, false, ); + + // Current temp small label + let now_str = format!("Now {:.0}°C", current_temp); out.text( - hvac_mode, - Rect::new(rect.x + 80., rect.y + 46., 120., 16.), - text(13.12, 400, 0.), - c(0xe8e8e8, 0.48), + &now_str, + Rect::new(rect.x + 16., rect.y + rect.h - 60., rect.w - 32., 16.), + text(12., 400, 0.02), + c(0xe8e8e8, 0.55), false, ); - // Current temperature - let current_str = format!("{:.1}°", current_temp); + + // Target/current temperature large + let temp_str = format!("{:.0}°", target_temp); out.text( - ¤t_str, - Rect::new(rect.x + rect.w - 200., rect.y + 16., 80., 28.), - text(24., 600, -0.03).right(), - WHITE, + &temp_str, + Rect::new(rect.x + 16., rect.y + rect.h - 42., rect.w - 32., 30.), + text(26., 700, -0.02), + if is_active { WHITE } else { c(0xbbbbbb, 0.5) }, false, ); - // Target temperature - let target_str = format!("→ {:.1}°", target_temp); - out.text( - &target_str, - Rect::new(rect.x + rect.w - 108., rect.y + 18., 88., 24.), - text(18., 400, 0.), - accent, - false, - ); - if focused { - out.icon(assets, "left", Rect::new(rect.x + rect.w - 108., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4)); - out.icon(assets, "right", Rect::new(rect.x + rect.w - 92., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4)); + + // Scroll hint when focused + if focused && is_active { + out.text( + "scroll", + Rect::new(rect.x + rect.w - 68., rect.y + rect.h - 28., 52., 14.), + text(11., 400, 0.03).right(), + c(0xeeeeee, 0.35), + false, + ); } + // History sparkline if available if let Some(points) = state.ha.history.get(&card.entity_id) { if points.len() >= 2 { - ha_sparkline(out, Rect::new(rect.x + 80., rect.y + 64., 300., 16.), points, accent); + ha_sparkline( + out, + Rect::new(rect.x + 76., rect.y + 64., rect.w - 92., 20.), + points, + [accent[0], accent[1], accent[2], 0.5], + ); } } } @@ -1974,24 +2105,41 @@ fn ha_action_card( ) { ha_glass(out, rect, focused); let accent = c(0xc9a0dc, 1.); + + // Icon in tinted circle + let icon_bg = [accent[0], accent[1], accent[2], 0.15]; + out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg); out.icon( assets, "scene", - Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + Rect::new(rect.x + 24., rect.y + 24., 32., 32.), accent, ); + + // Type label + let type_label = if card.entity_id.starts_with("script.") { "Script" } else { "Scene" }; + out.text( + type_label, + Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.), + text(12., 400, 0.04), + [accent[0], accent[1], accent[2], 0.7], + false, + ); + // Entity name out.text( &card.name, - Rect::new(rect.x + 80., rect.y + 28., rect.w - 120., 32.), - text(20., 500, -0.02), + Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), + text(18., 600, -0.02), WHITE, false, ); + + // Action hint if focused { out.text( "OK to activate", - Rect::new(rect.x + 80., rect.y + 60., 200., 16.), - text(12., 400, 0.), + Rect::new(rect.x + 16., rect.y + rect.h - 32., rect.w - 32., 16.), + text(12., 400, 0.02), c(0xe8e8e8, 0.40), false, ); @@ -2016,32 +2164,170 @@ fn ha_vacuum_card( "returning" => c(0xf0a060, 1.), _ => c(0xbbbbbb, 0.5), }; + + // Fill for active states + let fill_color = ha_fill_color(&card.card_type, entity); + if status == "cleaning" || status == "returning" { + ha_card_fill(out, rect, if status == "cleaning" { 0.6 } else { 0.3 }, fill_color); + } + + // Icon in tinted circle + let icon_bg = if status == "cleaning" || status == "returning" { + [accent[0], accent[1], accent[2], 0.2] + } else { + c(0xffffff, 0.08) + }; + out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg); out.icon( assets, "vacuum", - Rect::new(rect.x + 20., rect.y + 22., 44., 44.), + Rect::new(rect.x + 24., rect.y + 24., 32., 32.), accent, ); + + // Type label + out.text( + "Vacuum", + Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.), + text(12., 400, 0.04), + if status == "cleaning" { accent } else { c(0xe8e8e8, 0.45) }, + false, + ); + // Entity name out.text( &card.name, - Rect::new(rect.x + 80., rect.y + 22., rect.w - 120., 24.), - text(20., 500, -0.02), + Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), + text(18., 600, -0.02), WHITE, false, ); + + // Status at bottom + let display_status = match status { + "cleaning" => "Cleaning", + "returning" => "Returning", + "docked" => "Docked", + "idle" => "Idle", + "paused" => "Paused", + other => other, + }; out.text( - status, - Rect::new(rect.x + 80., rect.y + 52., rect.w - 120., 16.), - text(13.12, 400, 0.), - c(0xe8e8e8, 0.48), + display_status, + Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.), + text(22., 700, -0.02), + if status == "cleaning" || status == "returning" { WHITE } else { c(0xbbbbbb, 0.5) }, false, ); - if focused { +} + +// ── Media card (Music Assistant / media_player) ──────────────── + +fn ha_media_card( + out: &mut Scene, + _state: &State, + assets: &Assets, + rect: Rect, + card: &crate::state::HaCardConfig, + entity: Option<&crate::state::HaEntity>, + focused: bool, +) { + ha_glass(out, rect, focused); + let accent = ha_state_color(entity); + let is_playing = entity.is_some_and(|e| e.state == "playing"); + let is_active = entity.is_some_and(|e| e.state == "playing" || e.state == "paused"); + + // Volume fill background + let volume = entity + .and_then(|e| e.attributes.get("volume_level")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.) as f32; + if is_active && volume > 0. { + let fill_color = ha_fill_color(&card.card_type, entity); + ha_card_fill(out, rect, volume, fill_color); + } + + // Album art circle (right side) + let art_size = 96.; + let art_x = rect.x + rect.w - art_size - 20.; + let art_y = rect.y + (rect.h - art_size) / 2.; + out.shape( + Rect::new(art_x, art_y, art_size, art_size), + art_size / 2., + if is_active { [accent[0], accent[1], accent[2], 0.25] } else { c(0xffffff, 0.08) }, + ); + out.icon( + assets, + "spotify", + Rect::new(art_x + 24., art_y + 24., 48., 48.), + if is_active { accent } else { c(0xbbbbbb, 0.4) }, + ); + + // Media source / state label + let media_source = entity + .and_then(|e| e.attributes.get("media_content_type")) + .and_then(|v| v.as_str()) + .unwrap_or("Media"); + let state_str = entity.map_or("off", |e| e.state.as_str()); + let status_label = match state_str { + "playing" => format!("{} · Playing", media_source), + "paused" => format!("{} · Paused", media_source), + "idle" => "Idle".into(), + _ => "Off".into(), + }; + out.text( + &status_label, + Rect::new(rect.x + 16., rect.y + 16., rect.w - art_size - 56., 14.), + text(12., 400, 0.03), + if is_active { accent } else { c(0xe8e8e8, 0.45) }, + false, + ); + + // Song title + let title = entity + .and_then(|e| e.attributes.get("media_title")) + .and_then(|v| v.as_str()) + .unwrap_or(&card.name); + out.text( + title, + Rect::new(rect.x + 16., rect.y + 36., rect.w - art_size - 56., 24.), + text(18., 600, -0.02), + WHITE, + false, + ); + + // Artist + let artist = entity + .and_then(|e| e.attributes.get("media_artist")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !artist.is_empty() { out.text( - "OK to open", - Rect::new(rect.x + rect.w - 120., rect.y + 36., 100., 16.), - text(12., 400, 0.).right(), - c(0xe8e8e8, 0.40), + artist, + Rect::new(rect.x + 16., rect.y + 64., rect.w - art_size - 56., 18.), + text(14., 400, 0.), + c(0xe8e8e8, 0.6), + false, + ); + } + + // Volume indicator at bottom + let vol_pct = (volume * 100.).round() as u8; + let vol_label = if is_active { format!("Vol {}%", vol_pct) } else { "Off".into() }; + out.text( + &vol_label, + Rect::new(rect.x + 16., rect.y + rect.h - 32., rect.w - art_size - 56., 18.), + text(14., 500, 0.), + if is_active { WHITE } else { c(0xbbbbbb, 0.5) }, + false, + ); + + // Scroll + OK hint when focused + if focused && is_active { + out.text( + "OK ▶/❚❚ scroll vol", + Rect::new(rect.x + 16., rect.y + rect.h - 16., rect.w - art_size - 56., 12.), + text(10., 400, 0.02), + c(0xeeeeee, 0.30), false, ); }