From 29b7e30844e8eaadc19bb5371a4b6c1d57e6954a Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Mon, 11 May 2026 11:05:38 +0200 Subject: [PATCH] feat: auto stream selection + M/S badge overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiosk pick_stream() implements CLAUDE.md heuristic: - cell area >= 20% of grid → main stream - cell area < 20% → sub stream - explicit "main"/"sub" selector still honored Badge overlay shows which stream is rendering: - 'M' when camera has multi-stream and we picked main - 'S' when we picked sub - nothing when single-stream Small label, top-left corner, semi-transparent black background. Reduces buffer drops on multi-camera grids — small cells now use low-res sub streams instead of all decoding 4K main. --- kiosk/src/bundle.rs | 40 ++++++++++++++++++++++++----- kiosk/src/ui.rs | 61 +++++++++++++++++++++++++++++++-------------- 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/kiosk/src/bundle.rs b/kiosk/src/bundle.rs index b3398fb..8d9299b 100644 --- a/kiosk/src/bundle.rs +++ b/kiosk/src/bundle.rs @@ -73,17 +73,45 @@ pub struct BundleStream { } impl BundleCamera { - /// Pick the best stream URI for this camera given a cell's stream_selector. + /// Pick stream URI + role tag for this camera given selector and cell area fraction. + /// Heuristic: when selector=auto, cell ≥20% of grid → main, else sub. + /// Returns (uri, role_letter) where role_letter is 'M' or 'S' (or empty if single stream). + pub fn pick_stream(&self, selector: Option<&str>, area_fraction: f32) -> Option<(String, char)> { + let has_main = self.streams.iter().any(|s| s.role == "main"); + let has_sub = self.streams.iter().any(|s| s.role == "sub"); + let multi = has_main && has_sub; + + let sel = selector.unwrap_or("auto"); + let role_pref = match sel { + "main" => "main", + "sub" => "sub", + _ => if area_fraction >= 0.2 { "main" } else { "sub" }, + }; + + let stream = self.streams.iter().find(|s| s.role == role_pref) + .or_else(|| self.streams.iter().find(|s| s.role == "main")) + .or_else(|| self.streams.first()); + + let uri = stream.map(|s| s.rtsp_uri.clone()) + .or_else(|| self.rtsp_url.clone())?; + let badge = if !multi { + ' ' + } else if stream.map(|s| s.role.as_str()) == Some("main") { + 'M' + } else { + 'S' + }; + Some((uri, badge)) + } + + /// Legacy single-arg stream picker (no heuristic). pub fn stream_uri(&self, selector: Option<&str>) -> Option<&str> { let sel = selector.unwrap_or("auto"); match sel { "main" => self.streams.iter().find(|s| s.role == "main"), "sub" => self.streams.iter().find(|s| s.role == "sub"), - _ => { - // auto: prefer main, fall back to any - self.streams.iter().find(|s| s.role == "main") - .or_else(|| self.streams.first()) - } + _ => self.streams.iter().find(|s| s.role == "main") + .or_else(|| self.streams.first()), } .map(|s| s.rtsp_uri.as_str()) .or(self.rtsp_url.as_deref()) diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 8afc32f..3c0e585 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -3,9 +3,10 @@ use std::sync::mpsc; use url::Url; thread_local! { - /// camera_id → (pipeline, paintable). Pipelines stay warm across layout - /// swaps for cameras still referenced or in preload_camera_ids. - static WARM_CAMERAS: RefCell> + /// camera_id → (pipeline, paintable, badge). Pipelines stay warm across + /// layout swaps for cameras still referenced or in preload_camera_ids. + /// badge is 'M' / 'S' / ' ' indicating which stream is active. + static WARM_CAMERAS: RefCell> = RefCell::new(std::collections::HashMap::new()); } @@ -218,7 +219,7 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s let mut warm = w.borrow_mut(); let stale: Vec = warm.keys().filter(|id| !needed.contains(id)).copied().collect(); for id in stale { - if let Some((pipe, _)) = warm.remove(&id) { + if let Some((pipe, _, _)) = warm.remove(&id) { info!("stopping pipeline for camera {id} (no longer needed)"); pipeline::stop(&pipe); } @@ -234,10 +235,13 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s let cam_map: std::collections::HashMap = bundle.cameras.iter().map(|c| (c.id, c)).collect(); - // Ensure preloaded cameras have pipelines even if not visible + // Total grid area for the heuristic + let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32; + + // Ensure preloaded cameras have pipelines even if not visible (use sub for warmth) for cam_id in &layout.preload_camera_ids { if let Some(cam) = cam_map.get(cam_id) { - ensure_warm(*cam_id, cam, None); + ensure_warm(*cam_id, cam, None, 0.0); } } @@ -246,12 +250,27 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s "camera" => { if let Some(cam_id) = cell.camera_id { if let Some(cam) = cam_map.get(&cam_id) { - if let Some(paintable) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref()) { + let area = (cell.col_span * cell.row_span) as f32 / total_area; + if let Some((paintable, badge)) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) { let picture = Picture::for_paintable(&paintable); picture.set_content_fit(gtk::ContentFit::Cover); picture.set_vexpand(true); picture.set_hexpand(true); - picture.upcast() + // Wrap in Overlay so we can stack a stream-role badge on top + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&picture)); + overlay.set_vexpand(true); + overlay.set_hexpand(true); + if badge == 'M' || badge == 'S' { + let label = Label::new(Some(&badge.to_string())); + label.set_halign(gtk::Align::Start); + label.set_valign(gtk::Align::Start); + label.set_margin_start(4); + label.set_margin_top(4); + add_css(&label, "label { background: rgba(0,0,0,0.6); color: #fff; font-size: 11px; font-weight: 600; padding: 2px 6px; border-radius: 4px; min-width: 14px; }"); + overlay.add_overlay(&label); + } + overlay.upcast() } else { placeholder(Some(&format!("{} (no stream)", cam.name))) } @@ -304,7 +323,7 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s fn clear_warm_cameras() { WARM_CAMERAS.with(|w| { - for (_, (pipe, _)) in w.borrow().iter() { pipeline::stop(pipe); } + for (_, (pipe, _, _)) in w.borrow().iter() { pipeline::stop(pipe); } w.borrow_mut().clear(); }); } @@ -336,25 +355,29 @@ fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool { path.starts_with("/dash/") || path.starts_with("/in/kiosk/") } -/// Returns the paintable for a camera, creating a warm pipeline if missing. +/// Returns (paintable, badge_char) for a camera, creating a warm pipeline if missing. +/// badge is 'M' / 'S' (when multi-stream) or ' ' (single stream). fn ensure_warm( cam_id: u32, cam: &crate::bundle::BundleCamera, selector: Option<&str>, -) -> Option { - let existing = WARM_CAMERAS.with(|w| w.borrow().get(&cam_id).map(|(_, p)| p.clone())); - if let Some(p) = existing { - return Some(p); + area_fraction: f32, +) -> Option<(gtk::gdk::Paintable, char)> { + let existing = WARM_CAMERAS.with(|w| { + w.borrow().get(&cam_id).map(|(_, p, b)| (p.clone(), *b)) + }); + if let Some(pair) = existing { + return Some(pair); } - let uri = cam.stream_uri(selector)?; - let (pipe, sink) = pipeline::create_camera_pipeline(&cam.name, uri)?; + let (uri, badge) = cam.pick_stream(selector, area_fraction)?; + let (pipe, sink) = pipeline::create_camera_pipeline(&cam.name, &uri)?; let paintable = sink.property::("paintable"); pipeline::play(&pipe); WARM_CAMERAS.with(|w| { - w.borrow_mut().insert(cam_id, (pipe, paintable.clone())); + w.borrow_mut().insert(cam_id, (pipe, paintable.clone(), badge)); }); - info!("warmed pipeline for camera {cam_id}"); - Some(paintable) + info!("warmed pipeline for camera {cam_id} (stream: {badge})"); + Some((paintable, badge)) } fn show_logo(window: &ApplicationWindow) {