2026-05-12 23:00:11 +00:00
|
|
|
use std::cell::{Cell, RefCell};
|
2026-05-12 23:18:22 +00:00
|
|
|
use std::collections::HashMap;
|
2026-05-10 18:10:23 +00:00
|
|
|
use std::sync::mpsc;
|
2026-05-12 23:00:11 +00:00
|
|
|
use std::time::{Duration, Instant};
|
2026-05-11 08:44:45 +00:00
|
|
|
use url::Url;
|
2026-05-10 02:18:40 +00:00
|
|
|
|
|
|
|
|
use gtk4::prelude::*;
|
|
|
|
|
use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture};
|
|
|
|
|
use tracing::{info, warn};
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
use crate::bundle::{BundleDisplayWithLayouts, KioskBundle};
|
2026-05-10 20:45:56 +00:00
|
|
|
use crate::cec;
|
2026-05-12 23:18:22 +00:00
|
|
|
use crate::gpio;
|
2026-05-11 09:47:07 +00:00
|
|
|
use crate::hwmon;
|
2026-05-10 02:18:40 +00:00
|
|
|
use crate::pipeline;
|
|
|
|
|
use crate::server;
|
2026-05-10 20:15:58 +00:00
|
|
|
use crate::ws_client;
|
|
|
|
|
use crate::ServerMsg;
|
2026-05-10 02:18:40 +00:00
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
/// Per-display runtime state. Kept inside a thread-local hashmap keyed by
|
|
|
|
|
/// display id, so all the idle/sleep/layout tracking is local to that display
|
|
|
|
|
/// even though the GTK main loop is shared.
|
|
|
|
|
struct DisplayState {
|
|
|
|
|
window: ApplicationWindow,
|
|
|
|
|
current_layout_id: Option<u32>,
|
|
|
|
|
last_activity: Instant,
|
|
|
|
|
is_asleep: bool,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 00:42:37 +00:00
|
|
|
/// Pipeline lifecycle states (CLAUDE.md hot/warm/cooling/cold model):
|
|
|
|
|
/// - Hot: belongs to a priority=hot layout — keep warm forever
|
|
|
|
|
/// - Warm: actively rendered OR in active layout's preload list — decoding live
|
|
|
|
|
/// - Cooling: was warm, now not needed, kept alive until cooling_until
|
|
|
|
|
/// - Cold: removed from pool (no entry)
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
|
|
|
enum WarmthState {
|
|
|
|
|
Hot,
|
|
|
|
|
Warm,
|
|
|
|
|
Cooling,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct PipelineEntry {
|
|
|
|
|
pipeline: gstreamer::Pipeline,
|
|
|
|
|
paintable: gtk::gdk::Paintable,
|
|
|
|
|
state: WarmthState,
|
|
|
|
|
cooling_until: Option<Instant>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 11:00:35 +00:00
|
|
|
/// Pool key. A camera can have multiple concurrent pipelines — typically one
|
|
|
|
|
/// per (main, sub, other) stream — each with independent warmth state. When a
|
|
|
|
|
/// cell switches M↔S we promote the new variant to Warm/Hot but leave the old
|
|
|
|
|
/// one to cool down naturally so a quick swap back is instant.
|
|
|
|
|
type PoolKey = (u32, char);
|
|
|
|
|
|
2026-05-13 11:07:01 +00:00
|
|
|
/// WebView pool entry. Same Hot/Warm/Cooling/Cold lifecycle as cameras —
|
|
|
|
|
/// switching to a layout that doesn't reference a previously-loaded URL/HTML
|
|
|
|
|
/// leaves the WebView alive (unparented) so a fast switch-back preserves the
|
|
|
|
|
/// page state, JS execution, and avoids a full reload.
|
|
|
|
|
struct WebEntry {
|
|
|
|
|
webview: webkit6::WebView,
|
|
|
|
|
state: WarmthState,
|
|
|
|
|
cooling_until: Option<Instant>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Key for the webview pool. "web:<url>" for remote pages, "html:<hash>" for
|
|
|
|
|
/// inline HTML. Same content under either form across multiple cells/layouts
|
|
|
|
|
/// shares one WebView.
|
|
|
|
|
type WebKey = String;
|
|
|
|
|
|
2026-05-12 23:00:11 +00:00
|
|
|
thread_local! {
|
2026-05-13 11:00:35 +00:00
|
|
|
/// (camera_id, badge) → PipelineEntry. Pool shared across all displays.
|
2026-05-13 00:42:37 +00:00
|
|
|
/// State machine: see WarmthState. Entries dropped when state goes Cold.
|
2026-05-13 11:00:35 +00:00
|
|
|
static WARM_CAMERAS: RefCell<HashMap<PoolKey, PipelineEntry>>
|
2026-05-12 23:18:22 +00:00
|
|
|
= RefCell::new(HashMap::new());
|
2026-05-12 23:00:11 +00:00
|
|
|
|
2026-05-13 11:07:01 +00:00
|
|
|
/// Web/HTML cell pool. Same lifecycle as WARM_CAMERAS.
|
|
|
|
|
static WARM_WEBVIEWS: RefCell<HashMap<WebKey, WebEntry>>
|
|
|
|
|
= RefCell::new(HashMap::new());
|
|
|
|
|
|
2026-05-12 23:00:11 +00:00
|
|
|
/// Most recently rendered bundle. Used for layout-switch + idle revert.
|
|
|
|
|
static CURRENT_BUNDLE: RefCell<Option<KioskBundle>> = const { RefCell::new(None) };
|
|
|
|
|
|
|
|
|
|
/// Server URL + kiosk key for re-rendering on layout-switch.
|
|
|
|
|
static CURRENT_AUTH: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
|
|
|
|
|
|
2026-05-13 02:04:03 +00:00
|
|
|
/// Local time when the currently-rendered bundle was received by the UI.
|
|
|
|
|
static CURRENT_SYNC_LABEL: RefCell<String> = RefCell::new(String::from("unknown"));
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
/// Per-display state, keyed by bundle display id.
|
|
|
|
|
static DISPLAYS: RefCell<HashMap<u32, DisplayState>> = RefCell::new(HashMap::new());
|
2026-05-12 23:00:11 +00:00
|
|
|
|
|
|
|
|
/// Has the idle-watchdog already been installed on the main loop?
|
|
|
|
|
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 02:18:40 +00:00
|
|
|
const APP_ID: &str = "dev.betterframe.kiosk";
|
2026-05-11 07:38:50 +00:00
|
|
|
const BETTERFRAME_LOGO_SVG: &str = include_str!("../../server/src/web-static/betterframe-logo.svg");
|
|
|
|
|
const BETTERFRAME_MARK_SVG: &str = include_str!("../../server/src/web-static/betterframe-mark.svg");
|
2026-05-10 02:18:40 +00:00
|
|
|
|
|
|
|
|
pub fn build_app() -> Application {
|
|
|
|
|
let app = Application::builder().application_id(APP_ID).build();
|
|
|
|
|
app.connect_activate(activate);
|
|
|
|
|
app
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn activate(app: &Application) {
|
2026-05-12 23:18:22 +00:00
|
|
|
// Create the initial pairing window. Multi-display windows are spawned
|
|
|
|
|
// later once we receive a bundle.
|
|
|
|
|
let pairing_window = ApplicationWindow::builder()
|
2026-05-10 02:18:40 +00:00
|
|
|
.application(app)
|
|
|
|
|
.title("BetterFrame")
|
|
|
|
|
.fullscreened(true)
|
|
|
|
|
.build();
|
|
|
|
|
|
|
|
|
|
let provider = gtk::CssProvider::new();
|
2026-05-11 09:50:45 +00:00
|
|
|
provider.load_from_string("window { background-color: #000000; }");
|
2026-05-10 02:18:40 +00:00
|
|
|
gtk::style_context_add_provider_for_display(
|
2026-05-12 23:18:22 +00:00
|
|
|
&WidgetExt::display(&pairing_window),
|
2026-05-10 02:18:40 +00:00
|
|
|
&provider,
|
|
|
|
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-13 01:37:32 +00:00
|
|
|
hide_cursor_on(&pairing_window);
|
2026-05-12 23:18:22 +00:00
|
|
|
show_logo(&pairing_window);
|
|
|
|
|
pairing_window.present();
|
2026-05-10 02:18:40 +00:00
|
|
|
|
2026-05-10 18:10:23 +00:00
|
|
|
let (tx, rx) = mpsc::channel::<WorkerMsg>();
|
2026-05-10 02:18:40 +00:00
|
|
|
|
2026-05-10 18:11:31 +00:00
|
|
|
let server_url = std::env::var("BETTERFRAME_SERVER").ok()
|
|
|
|
|
.or_else(|| std::env::args().nth(1));
|
2026-05-10 02:18:40 +00:00
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let server = server::discover_server(server_url.as_deref());
|
|
|
|
|
info!("server: {server}");
|
|
|
|
|
|
|
|
|
|
let key = if server::is_paired() {
|
|
|
|
|
info!("already paired");
|
|
|
|
|
server::load_key()
|
|
|
|
|
} else {
|
|
|
|
|
let (code, expires) = server::initiate_pairing(&server);
|
|
|
|
|
info!("pairing code: {code} (expires {expires})");
|
2026-05-10 18:09:06 +00:00
|
|
|
let _ = tx.send(WorkerMsg::ShowPairingCode(code.clone()));
|
2026-05-10 02:18:40 +00:00
|
|
|
|
|
|
|
|
let (name, key) = server::poll_claim(&server, &code);
|
|
|
|
|
info!("paired as: {name}");
|
|
|
|
|
key
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-12 23:00:11 +00:00
|
|
|
// Try fetching live bundle. If server unreachable, fall back to
|
|
|
|
|
// cached on-disk bundle and keep retrying every 30s in the background.
|
|
|
|
|
let initial = match server::fetch_bundle(&server, &key) {
|
|
|
|
|
Some(b) => {
|
2026-05-12 23:18:22 +00:00
|
|
|
info!("bundle: {} cameras, {} display(s)", b.cameras.len(), b.normalized_displays().len());
|
2026-05-12 23:00:11 +00:00
|
|
|
Some(b)
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
if let Some(cached) = server::load_cached_bundle() {
|
|
|
|
|
warn!("offline mode: rendering cached bundle");
|
|
|
|
|
Some(cached)
|
|
|
|
|
} else {
|
|
|
|
|
warn!("no bundle available (server unreachable, no cache)");
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if let Some(bundle) = initial {
|
|
|
|
|
let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone()));
|
|
|
|
|
}
|
2026-05-10 02:18:40 +00:00
|
|
|
|
2026-05-10 20:15:58 +00:00
|
|
|
// Spawn WS client in a separate thread for live updates
|
|
|
|
|
let server_ws = server.clone();
|
|
|
|
|
let key_ws = key.clone();
|
|
|
|
|
let (ws_tx, ws_rx) = mpsc::channel::<ServerMsg>();
|
|
|
|
|
let tx_for_reload = tx.clone();
|
|
|
|
|
let server_for_reload = server.clone();
|
|
|
|
|
let key_for_reload = key.clone();
|
|
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
ws_client::run(&server_ws, &key_ws, ws_tx);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 23:00:11 +00:00
|
|
|
// Background retry thread: if we couldn't fetch a live bundle on boot,
|
|
|
|
|
// try again every 30s until we get one. Once fetched, send a render.
|
|
|
|
|
let retry_tx = tx.clone();
|
|
|
|
|
let retry_server = server.clone();
|
|
|
|
|
let retry_key = key.clone();
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
loop {
|
|
|
|
|
std::thread::sleep(Duration::from_secs(30));
|
|
|
|
|
if let Some(b) = server::fetch_bundle(&retry_server, &retry_key) {
|
|
|
|
|
info!("offline-retry: fresh bundle fetched, rendering");
|
|
|
|
|
let _ = retry_tx.send(WorkerMsg::RenderBundle(
|
|
|
|
|
b,
|
|
|
|
|
retry_server.clone(),
|
|
|
|
|
retry_key.clone(),
|
|
|
|
|
));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-10 20:45:56 +00:00
|
|
|
// Listen for WS messages and dispatch
|
2026-05-10 20:15:58 +00:00
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
for msg in ws_rx {
|
|
|
|
|
match msg {
|
|
|
|
|
ServerMsg::ReloadBundle => {
|
|
|
|
|
info!("reloading bundle");
|
2026-05-12 23:00:11 +00:00
|
|
|
match server::fetch_bundle(&server_for_reload, &key_for_reload) {
|
|
|
|
|
Some(bundle) => {
|
|
|
|
|
let _ = tx_for_reload.send(WorkerMsg::RenderBundle(
|
|
|
|
|
bundle,
|
|
|
|
|
server_for_reload.clone(),
|
|
|
|
|
key_for_reload.clone(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
None => warn!("reload-bundle: fetch failed, keeping current render"),
|
|
|
|
|
}
|
2026-05-10 20:15:58 +00:00
|
|
|
}
|
2026-05-10 20:46:30 +00:00
|
|
|
ServerMsg::Standby => cec::standby(),
|
2026-05-12 23:00:11 +00:00
|
|
|
ServerMsg::Wake => {
|
|
|
|
|
let _ = tx_for_reload.send(WorkerMsg::Wake);
|
|
|
|
|
}
|
2026-05-13 01:47:34 +00:00
|
|
|
ServerMsg::Fan(pwm) => {
|
|
|
|
|
if !hwmon::set_fan(pwm) {
|
|
|
|
|
warn!("fan command failed");
|
|
|
|
|
}
|
|
|
|
|
send_heartbeat_now(&server_for_reload, &key_for_reload);
|
|
|
|
|
}
|
2026-05-12 23:00:11 +00:00
|
|
|
ServerMsg::SwitchLayout(id) => {
|
|
|
|
|
let _ = tx_for_reload.send(WorkerMsg::SwitchLayout(id));
|
|
|
|
|
}
|
2026-05-10 20:15:58 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 01:39:03 +00:00
|
|
|
// Heartbeat loop — reports display geometry + hwmon. Fire once
|
|
|
|
|
// immediately so admin "Hardware" panel populates without waiting a
|
|
|
|
|
// full minute after boot/pair.
|
2026-05-10 02:18:40 +00:00
|
|
|
loop {
|
2026-05-13 01:47:34 +00:00
|
|
|
send_heartbeat_now(&server, &key);
|
2026-05-13 01:39:03 +00:00
|
|
|
std::thread::sleep(std::time::Duration::from_secs(60));
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-10 18:10:23 +00:00
|
|
|
// Poll channel from UI thread via timeout
|
2026-05-12 23:18:22 +00:00
|
|
|
let app_clone = app.clone();
|
|
|
|
|
let pairing_window_clone = pairing_window.clone();
|
2026-05-10 18:10:23 +00:00
|
|
|
gtk::glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
|
|
|
|
while let Ok(msg) = rx.try_recv() {
|
|
|
|
|
match msg {
|
2026-05-12 23:18:22 +00:00
|
|
|
WorkerMsg::ShowPairingCode(code) => show_pairing_code(&pairing_window_clone, &code),
|
2026-05-12 23:00:11 +00:00
|
|
|
WorkerMsg::RenderBundle(bundle, server, key) => {
|
2026-05-12 23:18:22 +00:00
|
|
|
render_bundle(&app_clone, &pairing_window_clone, bundle, &server, &key);
|
|
|
|
|
install_idle_watchdog();
|
2026-05-12 23:00:11 +00:00
|
|
|
}
|
|
|
|
|
WorkerMsg::SwitchLayout(id) => {
|
2026-05-12 23:18:22 +00:00
|
|
|
switch_layout_anywhere(id);
|
2026-05-12 23:00:11 +00:00
|
|
|
}
|
|
|
|
|
WorkerMsg::Wake => {
|
|
|
|
|
cec::wake();
|
2026-05-12 23:18:22 +00:00
|
|
|
DISPLAYS.with(|ds| {
|
|
|
|
|
for st in ds.borrow_mut().values_mut() {
|
|
|
|
|
st.is_asleep = false;
|
|
|
|
|
st.last_activity = Instant::now();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-12 23:00:11 +00:00
|
|
|
}
|
2026-05-10 18:10:23 +00:00
|
|
|
}
|
2026-05-10 18:09:06 +00:00
|
|
|
}
|
|
|
|
|
gtk::glib::ControlFlow::Continue
|
|
|
|
|
});
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 18:09:06 +00:00
|
|
|
enum WorkerMsg {
|
|
|
|
|
ShowPairingCode(String),
|
2026-05-11 08:44:45 +00:00
|
|
|
RenderBundle(KioskBundle, String, String),
|
2026-05-12 23:00:11 +00:00
|
|
|
SwitchLayout(u32),
|
|
|
|
|
Wake,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
/// Reset activity timer for one display. If asleep, wake it.
|
|
|
|
|
fn mark_activity(display_id: u32) {
|
|
|
|
|
DISPLAYS.with(|ds| {
|
|
|
|
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
|
|
|
|
st.last_activity = Instant::now();
|
|
|
|
|
if st.is_asleep {
|
|
|
|
|
info!("activity while asleep → waking display {display_id}");
|
|
|
|
|
cec::wake();
|
|
|
|
|
st.is_asleep = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-12 23:00:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 01:47:34 +00:00
|
|
|
fn send_heartbeat_now(server_url: &str, kiosk_key: &str) {
|
|
|
|
|
let displays = query_displays();
|
|
|
|
|
let hw = hwmon::read();
|
|
|
|
|
server::heartbeat(server_url, kiosk_key, &displays, &hw);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
/// Install the once-per-second watchdog that enforces idle/sleep timeouts
|
|
|
|
|
/// per display. Safe to call multiple times — installs at most once.
|
|
|
|
|
fn install_idle_watchdog() {
|
2026-05-12 23:00:11 +00:00
|
|
|
if WATCHDOG_INSTALLED.with(|c| c.get()) { return; }
|
|
|
|
|
WATCHDOG_INSTALLED.with(|c| c.set(true));
|
|
|
|
|
gtk::glib::timeout_add_local(Duration::from_secs(1), move || {
|
2026-05-13 11:07:01 +00:00
|
|
|
// Drop any pipelines / webviews whose cooling window has elapsed.
|
2026-05-13 00:59:22 +00:00
|
|
|
expire_cooling_pipelines();
|
2026-05-13 11:07:01 +00:00
|
|
|
expire_cooling_webviews();
|
2026-05-13 00:59:22 +00:00
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
|
|
|
|
|
let Some(bundle) = bundle else { return gtk::glib::ControlFlow::Continue };
|
|
|
|
|
|
|
|
|
|
// Snapshot per-display timing decisions so we can act outside the borrow.
|
|
|
|
|
struct Action { display_id: u32, revert_to: Option<u32>, sleep: bool }
|
|
|
|
|
let mut actions: Vec<Action> = Vec::new();
|
|
|
|
|
|
|
|
|
|
DISPLAYS.with(|ds| {
|
|
|
|
|
for (display_id, st) in ds.borrow().iter() {
|
|
|
|
|
let Some(d) = bundle.normalized_displays().into_iter().find(|d| d.id == *display_id) else { continue };
|
|
|
|
|
let idle_to = d.idle_timeout_seconds as u64;
|
|
|
|
|
let sleep_to = d.sleep_timeout_seconds as u64;
|
|
|
|
|
let elapsed = st.last_activity.elapsed();
|
|
|
|
|
let default_id = d.default_layout_id;
|
|
|
|
|
|
|
|
|
|
let mut act = Action { display_id: *display_id, revert_to: None, sleep: false };
|
|
|
|
|
|
|
|
|
|
if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
|
|
|
|
|
let cur_resets_idle = st.current_layout_id
|
|
|
|
|
.and_then(|cur_id| d.layouts.iter().find(|l| l.id == cur_id))
|
|
|
|
|
.map(|l| l.resets_idle_timer)
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
if let (Some(cur_id), Some(def_id)) = (st.current_layout_id, default_id) {
|
|
|
|
|
if cur_id != def_id && cur_resets_idle {
|
|
|
|
|
act.revert_to = Some(def_id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if sleep_to > 0 && elapsed >= Duration::from_secs(sleep_to) && !st.is_asleep {
|
|
|
|
|
act.sleep = true;
|
|
|
|
|
}
|
|
|
|
|
if act.revert_to.is_some() || act.sleep {
|
|
|
|
|
actions.push(act);
|
|
|
|
|
}
|
2026-05-12 23:00:11 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
for a in actions {
|
|
|
|
|
if let Some(layout_id) = a.revert_to {
|
|
|
|
|
info!("idle timeout reached → reverting display {} to default", a.display_id);
|
|
|
|
|
render_layout(a.display_id, layout_id);
|
|
|
|
|
}
|
|
|
|
|
if a.sleep {
|
|
|
|
|
info!("sleep timeout reached on display {} → CEC standby", a.display_id);
|
|
|
|
|
cec::standby();
|
|
|
|
|
DISPLAYS.with(|ds| {
|
|
|
|
|
if let Some(st) = ds.borrow_mut().get_mut(&a.display_id) {
|
|
|
|
|
st.is_asleep = true;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-12 23:00:11 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
gtk::glib::ControlFlow::Continue
|
|
|
|
|
});
|
2026-05-10 18:09:06 +00:00
|
|
|
}
|
2026-05-10 02:18:40 +00:00
|
|
|
|
2026-05-10 20:39:53 +00:00
|
|
|
/// Query connected HDMI displays from sysfs. Returns (name, width, height).
|
|
|
|
|
/// Reads /sys/class/drm/*/status and /sys/class/drm/*/modes.
|
|
|
|
|
fn query_displays() -> Vec<(String, u32, u32)> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { return out };
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
|
|
|
if !name.contains("-HDMI-") && !name.contains("-DP-") { continue; }
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
let status = std::fs::read_to_string(path.join("status")).unwrap_or_default();
|
|
|
|
|
if status.trim() != "connected" { continue; }
|
|
|
|
|
let modes = std::fs::read_to_string(path.join("modes")).unwrap_or_default();
|
|
|
|
|
let mode = modes.lines().next().unwrap_or("");
|
|
|
|
|
let parts: Vec<&str> = mode.split('x').collect();
|
|
|
|
|
if parts.len() != 2 { continue; }
|
|
|
|
|
let w: u32 = parts[0].parse().unwrap_or(0);
|
|
|
|
|
let h: u32 = parts[1].trim().parse().unwrap_or(0);
|
|
|
|
|
if w == 0 || h == 0 { continue; }
|
|
|
|
|
let clean_name = name.split_once('-').map(|(_, rest)| rest.to_string()).unwrap_or(name);
|
|
|
|
|
out.push((clean_name, w, h));
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 18:09:06 +00:00
|
|
|
fn show_pairing_code(window: &ApplicationWindow, code: &str) {
|
2026-05-10 02:18:40 +00:00
|
|
|
let vbox = GtkBox::new(Orientation::Vertical, 20);
|
|
|
|
|
vbox.set_valign(gtk::Align::Center);
|
|
|
|
|
vbox.set_halign(gtk::Align::Center);
|
|
|
|
|
vbox.set_vexpand(true);
|
|
|
|
|
|
2026-05-11 07:38:50 +00:00
|
|
|
let title = logo_picture(BETTERFRAME_LOGO_SVG, 360, 88, "pairing-logo");
|
2026-05-10 02:18:40 +00:00
|
|
|
|
|
|
|
|
let code_label = Label::new(Some(code));
|
2026-05-10 18:09:06 +00:00
|
|
|
add_css(&code_label, ".code { font-size: 72px; color: #fff; font-weight: 700; letter-spacing: 12px; font-family: monospace; }");
|
|
|
|
|
code_label.add_css_class("code");
|
2026-05-10 02:18:40 +00:00
|
|
|
|
|
|
|
|
let hint = Label::new(Some("Enter this code in BetterFrame admin to pair"));
|
2026-05-10 18:09:06 +00:00
|
|
|
add_css(&hint, ".hint { font-size: 14px; color: #666; }");
|
2026-05-10 02:18:40 +00:00
|
|
|
hint.add_css_class("hint");
|
|
|
|
|
|
|
|
|
|
vbox.append(&title);
|
|
|
|
|
vbox.append(&code_label);
|
|
|
|
|
vbox.append(&hint);
|
2026-05-13 01:39:03 +00:00
|
|
|
vbox.append(&spinner(28));
|
2026-05-10 18:09:06 +00:00
|
|
|
window.set_child(Some(&vbox));
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
/// Render a fresh bundle: rebuild the per-display window set, restart GPIO
|
|
|
|
|
/// workers, recompute warm-camera needs across all displays.
|
|
|
|
|
fn render_bundle(
|
|
|
|
|
app: &Application,
|
|
|
|
|
pairing_window: &ApplicationWindow,
|
|
|
|
|
bundle: KioskBundle,
|
|
|
|
|
server_url: &str,
|
|
|
|
|
kiosk_key: &str,
|
|
|
|
|
) {
|
2026-05-12 23:00:11 +00:00
|
|
|
CURRENT_BUNDLE.with(|b| *b.borrow_mut() = Some(bundle.clone()));
|
|
|
|
|
CURRENT_AUTH.with(|a| *a.borrow_mut() = Some((server_url.to_string(), kiosk_key.to_string())));
|
2026-05-13 02:04:03 +00:00
|
|
|
CURRENT_SYNC_LABEL.with(|s| *s.borrow_mut() = format_current_local_time());
|
2026-05-12 23:00:11 +00:00
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
// Restart GPIO workers (always — even if list is empty, this drops the old set).
|
|
|
|
|
gpio::start_workers(&bundle.gpio_bindings, server_url, kiosk_key);
|
2026-05-12 23:00:11 +00:00
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
let displays = bundle.normalized_displays();
|
|
|
|
|
if displays.is_empty() {
|
|
|
|
|
warn!("bundle has no displays");
|
|
|
|
|
show_logo(pairing_window);
|
2026-05-12 23:00:11 +00:00
|
|
|
return;
|
2026-05-12 23:18:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Match GDK monitors to bundle displays by index. Bundle display 0 → GDK
|
|
|
|
|
// monitor 0, etc. v1 simple ordering — re-binding will land if/when the
|
|
|
|
|
// admin UI exposes a mapping. Falls back to overlapping windows on a
|
|
|
|
|
// single physical screen if the kiosk has fewer monitors than bundle
|
|
|
|
|
// displays (rare on Pi5).
|
|
|
|
|
let gdk_monitors: Vec<gtk::gdk::Monitor> = WidgetExt::display(pairing_window)
|
|
|
|
|
.monitors()
|
|
|
|
|
.iter::<gtk::gdk::Monitor>()
|
|
|
|
|
.flatten()
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Tear down any previous per-display windows we no longer need.
|
|
|
|
|
let keep_ids: std::collections::HashSet<u32> = displays.iter().map(|d| d.id).collect();
|
|
|
|
|
let to_remove: Vec<u32> = DISPLAYS.with(|ds| {
|
|
|
|
|
ds.borrow().keys().filter(|id| !keep_ids.contains(id)).copied().collect()
|
|
|
|
|
});
|
|
|
|
|
for id in to_remove {
|
|
|
|
|
if let Some(st) = DISPLAYS.with(|ds| ds.borrow_mut().remove(&id)) {
|
|
|
|
|
st.window.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 00:59:22 +00:00
|
|
|
// Note: hot/warm/cooling pool recompute is deferred to the per-display
|
|
|
|
|
// render_layout() calls below — each one calls recompute_global_state()
|
|
|
|
|
// after installing its current_layout_id, so the union across all
|
|
|
|
|
// displays is correct once the loop finishes.
|
2026-05-12 23:18:22 +00:00
|
|
|
|
|
|
|
|
// Build/reuse window per bundle display, then render its initial layout.
|
|
|
|
|
let mut new_state: HashMap<u32, DisplayState> = HashMap::new();
|
|
|
|
|
for (i, bd) in displays.iter().enumerate() {
|
|
|
|
|
let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id));
|
|
|
|
|
let window = match existing {
|
|
|
|
|
Some(st) => st.window,
|
|
|
|
|
None => {
|
|
|
|
|
let w = ApplicationWindow::builder()
|
|
|
|
|
.application(app)
|
|
|
|
|
.title(format!("BetterFrame — {}", bd.name))
|
|
|
|
|
.fullscreened(true)
|
|
|
|
|
.build();
|
|
|
|
|
let provider = gtk::CssProvider::new();
|
|
|
|
|
provider.load_from_string("window { background-color: #000000; }");
|
|
|
|
|
gtk::style_context_add_provider_for_display(
|
|
|
|
|
&WidgetExt::display(&w),
|
|
|
|
|
&provider,
|
|
|
|
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
|
|
|
);
|
2026-05-13 01:37:32 +00:00
|
|
|
hide_cursor_on(&w);
|
2026-05-12 23:18:22 +00:00
|
|
|
w.present();
|
|
|
|
|
if let Some(monitor) = gdk_monitors.get(i) {
|
|
|
|
|
w.fullscreen_on_monitor(monitor);
|
|
|
|
|
}
|
|
|
|
|
w
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
new_state.insert(bd.id, DisplayState {
|
|
|
|
|
window,
|
|
|
|
|
current_layout_id: None,
|
|
|
|
|
last_activity: Instant::now(),
|
|
|
|
|
is_asleep: false,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
DISPLAYS.with(|ds| *ds.borrow_mut() = new_state);
|
|
|
|
|
|
|
|
|
|
// Hide the pairing window now that real displays are up (if we created any).
|
|
|
|
|
if !displays.is_empty() {
|
|
|
|
|
pairing_window.set_visible(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Now render each display's initial layout.
|
|
|
|
|
for bd in &displays {
|
|
|
|
|
let target = pick_initial_layout(bd);
|
|
|
|
|
if let Some(layout_id) = target {
|
|
|
|
|
render_layout(bd.id, layout_id);
|
|
|
|
|
} else {
|
|
|
|
|
warn!("display {} has no default layout", bd.id);
|
|
|
|
|
DISPLAYS.with(|ds| {
|
|
|
|
|
if let Some(st) = ds.borrow_mut().get_mut(&bd.id) {
|
2026-05-13 02:04:03 +00:00
|
|
|
show_empty_display_reference(&st.window, &bundle, bd);
|
2026-05-12 23:18:22 +00:00
|
|
|
st.current_layout_id = None;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-12 23:00:11 +00:00
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<u32> {
|
|
|
|
|
bd.default_layout_id
|
|
|
|
|
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id))
|
|
|
|
|
.or_else(|| bd.layouts.first().map(|l| l.id))
|
2026-05-12 23:00:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
/// Find which display owns a given layout_id and render it there.
|
|
|
|
|
fn switch_layout_anywhere(layout_id: u32) {
|
|
|
|
|
let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
|
|
|
|
|
let Some(bundle) = bundle else { return };
|
|
|
|
|
for bd in bundle.normalized_displays() {
|
|
|
|
|
if bd.layouts.iter().any(|l| l.id == layout_id) {
|
|
|
|
|
render_layout(bd.id, layout_id);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
warn!("switch_layout: layout {layout_id} not found on any display");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Render a specific layout id on a specific display.
|
|
|
|
|
fn render_layout(display_id: u32, layout_id: u32) {
|
|
|
|
|
mark_activity(display_id);
|
2026-05-12 23:00:11 +00:00
|
|
|
|
|
|
|
|
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
|
|
|
|
|
let bundle = b.borrow();
|
|
|
|
|
let bundle = bundle.as_ref()?.clone();
|
|
|
|
|
let auth = CURRENT_AUTH.with(|a| a.borrow().clone());
|
|
|
|
|
let (server_url, kiosk_key) = auth?;
|
|
|
|
|
Some((bundle, server_url, kiosk_key))
|
|
|
|
|
});
|
|
|
|
|
let Some((bundle, server_url, kiosk_key)) = snapshot else {
|
|
|
|
|
warn!("render_layout: no cached bundle yet");
|
|
|
|
|
return;
|
2026-05-11 07:51:00 +00:00
|
|
|
};
|
2026-05-10 02:18:40 +00:00
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
let displays = bundle.normalized_displays();
|
|
|
|
|
let Some(bd) = displays.iter().find(|d| d.id == display_id) else {
|
|
|
|
|
warn!("render_layout: display {display_id} not in bundle");
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let layout = bd.layouts.iter().find(|l| l.id == layout_id)
|
2026-05-12 23:00:11 +00:00
|
|
|
.or_else(|| {
|
2026-05-12 23:18:22 +00:00
|
|
|
warn!("render_layout: layout {layout_id} not on display {display_id}, falling back to default");
|
|
|
|
|
bd.default_layout_id
|
|
|
|
|
.and_then(|did| bd.layouts.iter().find(|l| l.id == did))
|
|
|
|
|
.or_else(|| bd.layouts.iter().find(|l| l.is_default))
|
2026-05-12 23:00:11 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-10 02:18:40 +00:00
|
|
|
let Some(layout) = layout else {
|
2026-05-12 23:18:22 +00:00
|
|
|
warn!("render_layout: no usable layout on display {display_id}");
|
|
|
|
|
DISPLAYS.with(|ds| {
|
|
|
|
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
2026-05-13 02:04:03 +00:00
|
|
|
show_empty_display_reference(&st.window, &bundle, bd);
|
2026-05-12 23:18:22 +00:00
|
|
|
st.current_layout_id = None;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-10 02:18:40 +00:00
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
// Update per-display layout id BEFORE recomputing warm-cameras so the
|
|
|
|
|
// union across displays is correct.
|
2026-05-13 11:03:51 +00:00
|
|
|
let previous_layout_id = DISPLAYS.with(|ds| {
|
|
|
|
|
let prev = ds.borrow().get(&display_id).and_then(|s| s.current_layout_id);
|
2026-05-12 23:18:22 +00:00
|
|
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
|
|
|
|
st.current_layout_id = Some(layout.id);
|
|
|
|
|
}
|
2026-05-13 11:03:51 +00:00
|
|
|
prev
|
2026-05-12 23:18:22 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)",
|
|
|
|
|
layout.name, layout.id, display_id, layout.grid_cols, layout.grid_rows, layout.cells.len());
|
|
|
|
|
|
2026-05-13 11:03:51 +00:00
|
|
|
// Notify the server when the active layout actually changes so Node-RED
|
|
|
|
|
// sees idle reverts + any other kiosk-initiated switch. Skip when the
|
|
|
|
|
// layout id is unchanged (re-render of the same layout).
|
|
|
|
|
if previous_layout_id != Some(layout.id) {
|
|
|
|
|
let layout_name = layout.name.clone();
|
|
|
|
|
let layout_id_for_report = layout.id;
|
|
|
|
|
let server = server_url.clone();
|
|
|
|
|
let key = kiosk_key.clone();
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
server::report_layout_change(&server, &key, display_id, layout_id_for_report, &layout_name);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 19:55:19 +00:00
|
|
|
if layout.cells.is_empty() {
|
|
|
|
|
warn!("layout has no cells");
|
2026-05-13 00:59:22 +00:00
|
|
|
recompute_global_state();
|
2026-05-12 23:18:22 +00:00
|
|
|
DISPLAYS.with(|ds| {
|
|
|
|
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
|
|
|
|
show_logo(&st.window);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-10 02:18:40 +00:00
|
|
|
return;
|
2026-05-10 19:39:09 +00:00
|
|
|
}
|
2026-05-10 02:18:40 +00:00
|
|
|
|
2026-05-13 00:59:22 +00:00
|
|
|
// Recompute hot/warm/cooling pool state across ALL displays' current
|
|
|
|
|
// layouts. Pipelines no longer needed transition to Cooling and are
|
|
|
|
|
// dropped by the watchdog tick after cooling_timeout_seconds.
|
|
|
|
|
recompute_global_state();
|
2026-05-10 20:51:28 +00:00
|
|
|
|
2026-05-12 23:00:11 +00:00
|
|
|
let server_url = server_url.as_str();
|
|
|
|
|
let kiosk_key = kiosk_key.as_str();
|
|
|
|
|
|
2026-05-10 02:18:40 +00:00
|
|
|
let grid = Grid::new();
|
|
|
|
|
grid.set_row_homogeneous(true);
|
|
|
|
|
grid.set_column_homogeneous(true);
|
|
|
|
|
grid.set_vexpand(true);
|
|
|
|
|
grid.set_hexpand(true);
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
|
2026-05-10 02:18:40 +00:00
|
|
|
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
|
|
|
|
|
2026-05-11 09:05:38 +00:00
|
|
|
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
// Ensure preloaded cameras have pipelines even if not visible.
|
2026-05-10 20:51:28 +00:00
|
|
|
for cam_id in &layout.preload_camera_ids {
|
|
|
|
|
if let Some(cam) = cam_map.get(cam_id) {
|
2026-05-11 09:05:38 +00:00
|
|
|
ensure_warm(*cam_id, cam, None, 0.0);
|
2026-05-10 20:51:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-10 02:18:40 +00:00
|
|
|
|
|
|
|
|
for cell in &layout.cells {
|
|
|
|
|
let widget: gtk::Widget = match cell.content_type.as_str() {
|
|
|
|
|
"camera" => {
|
|
|
|
|
if let Some(cam_id) = cell.camera_id {
|
|
|
|
|
if let Some(cam) = cam_map.get(&cam_id) {
|
2026-05-11 09:05:38 +00:00
|
|
|
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) {
|
2026-05-10 20:51:28 +00:00
|
|
|
let picture = Picture::for_paintable(&paintable);
|
2026-05-11 11:52:22 +00:00
|
|
|
picture.set_content_fit(match cell.fit.as_str() {
|
|
|
|
|
"contain" => gtk::ContentFit::Contain,
|
|
|
|
|
"fill" => gtk::ContentFit::Fill,
|
|
|
|
|
_ => gtk::ContentFit::Cover,
|
|
|
|
|
});
|
2026-05-10 20:51:28 +00:00
|
|
|
picture.set_vexpand(true);
|
|
|
|
|
picture.set_hexpand(true);
|
2026-05-11 09:05:38 +00:00
|
|
|
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()
|
2026-05-10 02:18:40 +00:00
|
|
|
} else {
|
2026-05-11 07:38:50 +00:00
|
|
|
placeholder(Some(&format!("{} (no stream)", cam.name)))
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-05-11 07:38:50 +00:00
|
|
|
placeholder(Some("Unknown camera"))
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-05-11 07:38:50 +00:00
|
|
|
none_cell()
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"html" => {
|
2026-05-10 20:39:53 +00:00
|
|
|
let html = cell.html_content.as_deref().unwrap_or("");
|
2026-05-11 07:38:50 +00:00
|
|
|
if html.trim().is_empty() {
|
|
|
|
|
none_cell()
|
|
|
|
|
} else {
|
2026-05-13 11:07:01 +00:00
|
|
|
let key = html_key(html);
|
|
|
|
|
ensure_web(key, WebSource::Html(html), server_url, kiosk_key).upcast()
|
2026-05-11 07:38:50 +00:00
|
|
|
}
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
"web" => {
|
2026-05-11 07:38:50 +00:00
|
|
|
let url = cell.web_url.as_deref().unwrap_or("").trim();
|
|
|
|
|
if url.is_empty() {
|
|
|
|
|
none_cell()
|
|
|
|
|
} else {
|
2026-05-13 11:07:01 +00:00
|
|
|
let key = format!("web:{url}");
|
|
|
|
|
ensure_web(key, WebSource::Url(url), server_url, kiosk_key).upcast()
|
2026-05-11 07:38:50 +00:00
|
|
|
}
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
2026-05-11 07:38:50 +00:00
|
|
|
"none" => none_cell(),
|
|
|
|
|
_ => placeholder(Some("Unknown content")),
|
2026-05-10 02:18:40 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
grid.attach(
|
|
|
|
|
&widget,
|
2026-05-10 19:55:19 +00:00
|
|
|
cell.col as i32,
|
|
|
|
|
cell.row as i32,
|
|
|
|
|
cell.col_span as i32,
|
|
|
|
|
cell.row_span as i32,
|
2026-05-10 02:18:40 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
DISPLAYS.with(|ds| {
|
|
|
|
|
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
|
|
|
|
st.window.set_child(Some(&grid));
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-10 20:51:28 +00:00
|
|
|
}
|
2026-05-10 02:18:40 +00:00
|
|
|
|
2026-05-13 00:59:22 +00:00
|
|
|
/// Default cooling timeout when a layout doesn't specify one (or specifies 0).
|
|
|
|
|
const DEFAULT_COOLING_SECS: u32 = 30;
|
|
|
|
|
|
|
|
|
|
/// Walk all displays' currently-active layouts (plus any priority=hot layouts)
|
2026-05-13 11:00:35 +00:00
|
|
|
/// and recompute the warm/hot pool. Pool entries dropped from active layouts
|
2026-05-13 00:59:22 +00:00
|
|
|
/// transition to Cooling; new entries are NOT added here — `ensure_warm` does
|
|
|
|
|
/// that when the layout actually renders.
|
2026-05-13 11:00:35 +00:00
|
|
|
///
|
|
|
|
|
/// Pool keys are (camera_id, badge): a camera's main and sub streams are
|
|
|
|
|
/// tracked independently, so flipping a cell from M→S promotes the new sub
|
|
|
|
|
/// pipeline to Warm/Hot but leaves the existing main pipeline to cool down
|
|
|
|
|
/// naturally (and vice-versa).
|
2026-05-13 00:59:22 +00:00
|
|
|
fn recompute_global_state() {
|
|
|
|
|
let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
|
|
|
|
|
let Some(bundle) = bundle else { return };
|
2026-05-12 23:18:22 +00:00
|
|
|
let displays = bundle.normalized_displays();
|
2026-05-13 00:59:22 +00:00
|
|
|
|
2026-05-13 11:00:35 +00:00
|
|
|
let mut warm_set: std::collections::HashSet<PoolKey> = std::collections::HashSet::new();
|
|
|
|
|
let mut hot_set: std::collections::HashSet<PoolKey> = std::collections::HashSet::new();
|
2026-05-13 00:59:22 +00:00
|
|
|
let mut max_cooling_secs: u32 = 0;
|
|
|
|
|
|
2026-05-13 11:00:35 +00:00
|
|
|
let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
|
|
|
|
|
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
|
|
|
|
|
2026-05-13 00:59:22 +00:00
|
|
|
// Snapshot per-display active layout id outside any borrow of WARM_CAMERAS.
|
|
|
|
|
let active: Vec<(u32, Option<u32>)> = DISPLAYS.with(|ds| {
|
|
|
|
|
ds.borrow().iter().map(|(id, st)| (*id, st.current_layout_id)).collect()
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 11:00:35 +00:00
|
|
|
// Helper: compute the pool key (camera_id, badge) for a given cell in a
|
|
|
|
|
// layout. Falls back to a "?" badge if pick_stream can't decide (camera
|
|
|
|
|
// missing or no streams).
|
|
|
|
|
fn cell_keys(
|
|
|
|
|
layout: &crate::bundle::BundleLayout,
|
|
|
|
|
cam_map: &HashMap<u32, &crate::bundle::BundleCamera>,
|
|
|
|
|
out: &mut std::collections::HashSet<PoolKey>,
|
|
|
|
|
) {
|
|
|
|
|
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
|
|
|
|
|
for cell in &layout.cells {
|
|
|
|
|
if cell.content_type != "camera" { continue; }
|
|
|
|
|
let Some(cam_id) = cell.camera_id else { continue };
|
|
|
|
|
let Some(cam) = cam_map.get(&cam_id) else { continue };
|
|
|
|
|
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
|
|
|
|
if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) {
|
|
|
|
|
out.insert((cam_id, badge));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Preload cameras have no cell context — let pick_stream choose
|
|
|
|
|
// (typically sub). Different layouts that actually render them will
|
|
|
|
|
// promote whichever badge they end up using.
|
|
|
|
|
for cam_id in &layout.preload_camera_ids {
|
|
|
|
|
if let Some(cam) = cam_map.get(cam_id) {
|
|
|
|
|
if let Some((_, badge)) = cam.pick_stream(None, 0.0) {
|
|
|
|
|
out.insert((*cam_id, badge));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 00:59:22 +00:00
|
|
|
for bd in &displays {
|
|
|
|
|
let active_id = active.iter().find(|(id, _)| *id == bd.id).and_then(|(_, l)| *l);
|
|
|
|
|
if let Some(cur_id) = active_id {
|
|
|
|
|
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
2026-05-13 11:00:35 +00:00
|
|
|
cell_keys(layout, &cam_map, &mut warm_set);
|
2026-05-13 00:59:22 +00:00
|
|
|
let t = layout.cooling_timeout_seconds.unwrap_or(0);
|
|
|
|
|
let t = if t == 0 { DEFAULT_COOLING_SECS } else { t };
|
|
|
|
|
max_cooling_secs = max_cooling_secs.max(t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for layout in &bd.layouts {
|
|
|
|
|
if layout.priority == "hot" {
|
2026-05-13 11:00:35 +00:00
|
|
|
cell_keys(layout, &cam_map, &mut hot_set);
|
2026-05-13 00:59:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 11:07:01 +00:00
|
|
|
// Same walk for web/html cells — pool keys are URL / hash(HTML).
|
|
|
|
|
let mut warm_webs: std::collections::HashSet<WebKey> = std::collections::HashSet::new();
|
|
|
|
|
let mut hot_webs: std::collections::HashSet<WebKey> = std::collections::HashSet::new();
|
|
|
|
|
for bd in &displays {
|
|
|
|
|
let active_id = active.iter().find(|(id, _)| *id == bd.id).and_then(|(_, l)| *l);
|
|
|
|
|
if let Some(cur_id) = active_id {
|
|
|
|
|
if let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) {
|
|
|
|
|
web_keys_for_layout(layout, &mut warm_webs);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for layout in &bd.layouts {
|
|
|
|
|
if layout.priority == "hot" {
|
|
|
|
|
web_keys_for_layout(layout, &mut hot_webs);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 00:59:22 +00:00
|
|
|
if max_cooling_secs == 0 { max_cooling_secs = DEFAULT_COOLING_SECS; }
|
|
|
|
|
recompute_pool_states(&warm_set, &hot_set, max_cooling_secs);
|
2026-05-13 11:07:01 +00:00
|
|
|
recompute_web_states(&warm_webs, &hot_webs, max_cooling_secs);
|
2026-05-13 00:59:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Apply the hot/warm/cooling/cold state machine to the existing WARM_CAMERAS
|
|
|
|
|
/// pool. Does NOT create new entries — `ensure_warm` handles that.
|
|
|
|
|
///
|
2026-05-13 11:00:35 +00:00
|
|
|
/// - key in hot_set → Hot (clear cooling)
|
|
|
|
|
/// - key in warm_set → Warm (clear cooling)
|
|
|
|
|
/// - key in neither & was Cooling → keep cooling_until unchanged
|
|
|
|
|
/// - key in neither & not yet cooling → transition to Cooling
|
2026-05-13 00:59:22 +00:00
|
|
|
/// - if max_cooling_secs == 0, remove immediately (Cold)
|
|
|
|
|
fn recompute_pool_states(
|
2026-05-13 11:00:35 +00:00
|
|
|
warm_set: &std::collections::HashSet<PoolKey>,
|
|
|
|
|
hot_set: &std::collections::HashSet<PoolKey>,
|
2026-05-13 00:59:22 +00:00
|
|
|
max_cooling_secs: u32,
|
|
|
|
|
) {
|
2026-05-13 11:00:35 +00:00
|
|
|
let mut to_remove: Vec<PoolKey> = Vec::new();
|
2026-05-13 00:59:22 +00:00
|
|
|
let mut to_stop: Vec<gstreamer::Pipeline> = Vec::new();
|
|
|
|
|
|
|
|
|
|
WARM_CAMERAS.with(|w| {
|
|
|
|
|
let mut warm = w.borrow_mut();
|
2026-05-13 11:00:35 +00:00
|
|
|
for (key, entry) in warm.iter_mut() {
|
|
|
|
|
if hot_set.contains(key) {
|
2026-05-13 00:59:22 +00:00
|
|
|
entry.state = WarmthState::Hot;
|
|
|
|
|
entry.cooling_until = None;
|
2026-05-13 11:00:35 +00:00
|
|
|
} else if warm_set.contains(key) {
|
2026-05-13 00:59:22 +00:00
|
|
|
entry.state = WarmthState::Warm;
|
|
|
|
|
entry.cooling_until = None;
|
|
|
|
|
} else {
|
|
|
|
|
if entry.state == WarmthState::Cooling {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if max_cooling_secs == 0 {
|
2026-05-13 11:00:35 +00:00
|
|
|
to_remove.push(*key);
|
2026-05-13 00:59:22 +00:00
|
|
|
to_stop.push(entry.pipeline.clone());
|
|
|
|
|
} else {
|
|
|
|
|
entry.state = WarmthState::Cooling;
|
|
|
|
|
entry.cooling_until = Some(
|
|
|
|
|
Instant::now() + Duration::from_secs(max_cooling_secs as u64),
|
|
|
|
|
);
|
|
|
|
|
info!(
|
2026-05-13 11:00:35 +00:00
|
|
|
"camera {} ({}): cooling for {}s before drop",
|
|
|
|
|
key.0, key.1, max_cooling_secs
|
2026-05-13 00:59:22 +00:00
|
|
|
);
|
2026-05-12 23:18:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 11:00:35 +00:00
|
|
|
for k in &to_remove { warm.remove(k); }
|
2026-05-12 23:18:22 +00:00
|
|
|
});
|
2026-05-13 00:59:22 +00:00
|
|
|
|
|
|
|
|
for pipe in to_stop {
|
|
|
|
|
pipeline::stop(&pipe);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drop any Cooling entries whose timer has expired. Called from the
|
|
|
|
|
/// 1s watchdog tick.
|
|
|
|
|
fn expire_cooling_pipelines() {
|
|
|
|
|
let now = Instant::now();
|
2026-05-13 11:00:35 +00:00
|
|
|
let mut expired: Vec<(PoolKey, gstreamer::Pipeline)> = Vec::new();
|
2026-05-11 07:51:00 +00:00
|
|
|
WARM_CAMERAS.with(|w| {
|
2026-05-12 23:18:22 +00:00
|
|
|
let mut warm = w.borrow_mut();
|
2026-05-13 11:00:35 +00:00
|
|
|
let keys: Vec<PoolKey> = warm
|
2026-05-13 00:59:22 +00:00
|
|
|
.iter()
|
|
|
|
|
.filter(|(_, e)| {
|
|
|
|
|
e.state == WarmthState::Cooling
|
|
|
|
|
&& e.cooling_until.is_some_and(|t| now >= t)
|
|
|
|
|
})
|
2026-05-13 11:00:35 +00:00
|
|
|
.map(|(k, _)| *k)
|
2026-05-13 00:59:22 +00:00
|
|
|
.collect();
|
2026-05-13 11:00:35 +00:00
|
|
|
for k in keys {
|
|
|
|
|
if let Some(e) = warm.remove(&k) {
|
|
|
|
|
expired.push((k, e.pipeline));
|
2026-05-12 23:18:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-11 07:51:00 +00:00
|
|
|
});
|
2026-05-13 11:00:35 +00:00
|
|
|
for (key, pipe) in expired {
|
|
|
|
|
info!("camera {} ({}): cooling expired → stopping pipeline", key.0, key.1);
|
2026-05-13 00:59:22 +00:00
|
|
|
pipeline::stop(&pipe);
|
|
|
|
|
}
|
2026-05-11 07:51:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-11 08:44:45 +00:00
|
|
|
fn load_webview_url(webview: &webkit6::WebView, url: &str, server_url: &str, kiosk_key: &str) {
|
|
|
|
|
if should_attach_kiosk_auth(url, server_url) {
|
|
|
|
|
let request = webkit6::URIRequest::new(url);
|
|
|
|
|
if let Some(headers) = request.http_headers() {
|
|
|
|
|
headers.append("Authorization", &format!("Bearer {kiosk_key}"));
|
|
|
|
|
}
|
|
|
|
|
webkit6::prelude::WebViewExt::load_request(webview, &request);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
webkit6::prelude::WebViewExt::load_uri(webview, url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool {
|
|
|
|
|
let Ok(target) = Url::parse(url) else { return false };
|
|
|
|
|
let Ok(server) = Url::parse(server_url) else { return false };
|
|
|
|
|
if target.scheme() != server.scheme()
|
|
|
|
|
|| target.host_str() != server.host_str()
|
|
|
|
|
|| target.port_or_known_default() != server.port_or_known_default()
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let path = target.path();
|
|
|
|
|
path.starts_with("/dash/") || path.starts_with("/in/kiosk/")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 11:00:35 +00:00
|
|
|
/// Returns (paintable, badge_char) for a camera, creating a warm pipeline if
|
|
|
|
|
/// the (cam, badge) variant isn't already in the pool. If the camera's OTHER
|
|
|
|
|
/// stream variant is cached (e.g. cell switched from main to sub), we leave
|
|
|
|
|
/// that sibling entry alone — recompute_pool_states will demote it to Cooling
|
|
|
|
|
/// so it can be reused if the cell flips back before the cooldown elapses.
|
2026-05-10 20:51:28 +00:00
|
|
|
fn ensure_warm(
|
|
|
|
|
cam_id: u32,
|
|
|
|
|
cam: &crate::bundle::BundleCamera,
|
|
|
|
|
selector: Option<&str>,
|
2026-05-11 09:05:38 +00:00
|
|
|
area_fraction: f32,
|
|
|
|
|
) -> Option<(gtk::gdk::Paintable, char)> {
|
2026-05-11 09:47:07 +00:00
|
|
|
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
|
2026-05-13 11:00:35 +00:00
|
|
|
let key: PoolKey = (cam_id, desired_badge);
|
2026-05-11 09:47:07 +00:00
|
|
|
|
|
|
|
|
let cached = WARM_CAMERAS.with(|w| {
|
2026-05-13 11:00:35 +00:00
|
|
|
w.borrow().get(&key).map(|e| (e.pipeline.clone(), e.paintable.clone()))
|
2026-05-11 09:05:38 +00:00
|
|
|
});
|
2026-05-13 11:00:35 +00:00
|
|
|
if let Some((_pipe, paintable)) = cached {
|
|
|
|
|
// Promote out of Cooling if we're rendering it again.
|
|
|
|
|
WARM_CAMERAS.with(|w| {
|
|
|
|
|
if let Some(e) = w.borrow_mut().get_mut(&key) {
|
|
|
|
|
if e.state == WarmthState::Cooling {
|
|
|
|
|
info!("camera {} ({}): rescued from cooling → warm", cam_id, desired_badge);
|
|
|
|
|
e.state = WarmthState::Warm;
|
|
|
|
|
e.cooling_until = None;
|
2026-05-13 00:59:22 +00:00
|
|
|
}
|
2026-05-13 11:00:35 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return Some((paintable, desired_badge));
|
2026-05-10 20:51:28 +00:00
|
|
|
}
|
2026-05-11 09:47:07 +00:00
|
|
|
|
2026-05-11 09:05:38 +00:00
|
|
|
let (pipe, sink) = pipeline::create_camera_pipeline(&cam.name, &uri)?;
|
2026-05-10 20:51:28 +00:00
|
|
|
let paintable = sink.property::<gtk::gdk::Paintable>("paintable");
|
|
|
|
|
pipeline::play(&pipe);
|
|
|
|
|
WARM_CAMERAS.with(|w| {
|
2026-05-13 11:00:35 +00:00
|
|
|
w.borrow_mut().insert(key, PipelineEntry {
|
2026-05-13 00:59:22 +00:00
|
|
|
pipeline: pipe,
|
|
|
|
|
paintable: paintable.clone(),
|
|
|
|
|
state: WarmthState::Warm,
|
|
|
|
|
cooling_until: None,
|
|
|
|
|
});
|
2026-05-10 02:18:40 +00:00
|
|
|
});
|
2026-05-11 09:47:07 +00:00
|
|
|
info!("warmed pipeline for camera {cam_id} (stream: {desired_badge})");
|
|
|
|
|
Some((paintable, desired_badge))
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 11:07:01 +00:00
|
|
|
enum WebSource<'a> {
|
|
|
|
|
Url(&'a str),
|
|
|
|
|
Html(&'a str),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stable key for an inline HTML cell. Hash the content so identical HTML in
|
|
|
|
|
/// two layouts/cells shares one WebView in the pool.
|
|
|
|
|
fn html_key(html: &str) -> WebKey {
|
|
|
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
|
|
|
html.hash(&mut h);
|
|
|
|
|
format!("html:{:x}", h.finish())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return a WebView for the given pool key, reusing a cached one if present.
|
|
|
|
|
/// On reuse, unparent first (GTK4 forbids attaching a widget with an existing
|
|
|
|
|
/// parent). On miss, build, load, and insert into the pool as Warm.
|
|
|
|
|
fn ensure_web(
|
|
|
|
|
key: WebKey,
|
|
|
|
|
source: WebSource<'_>,
|
|
|
|
|
server_url: &str,
|
|
|
|
|
kiosk_key: &str,
|
|
|
|
|
) -> webkit6::WebView {
|
|
|
|
|
let cached = WARM_WEBVIEWS.with(|m| {
|
|
|
|
|
m.borrow().get(&key).map(|e| e.webview.clone())
|
|
|
|
|
});
|
|
|
|
|
if let Some(wv) = cached {
|
|
|
|
|
WARM_WEBVIEWS.with(|m| {
|
|
|
|
|
if let Some(e) = m.borrow_mut().get_mut(&key) {
|
|
|
|
|
if e.state == WarmthState::Cooling {
|
|
|
|
|
info!("webview {key}: rescued from cooling → warm");
|
|
|
|
|
e.state = WarmthState::Warm;
|
|
|
|
|
e.cooling_until = None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Detach from previous container so the new grid can take it.
|
|
|
|
|
if wv.parent().is_some() {
|
|
|
|
|
wv.unparent();
|
|
|
|
|
}
|
|
|
|
|
return wv;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let wv = webkit6::WebView::new();
|
|
|
|
|
wv.set_vexpand(true);
|
|
|
|
|
wv.set_hexpand(true);
|
|
|
|
|
match source {
|
|
|
|
|
WebSource::Html(html) => {
|
|
|
|
|
webkit6::prelude::WebViewExt::load_html(&wv, html, None);
|
|
|
|
|
}
|
|
|
|
|
WebSource::Url(url) => {
|
|
|
|
|
load_webview_url(&wv, url, server_url, kiosk_key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
WARM_WEBVIEWS.with(|m| {
|
|
|
|
|
m.borrow_mut().insert(key.clone(), WebEntry {
|
|
|
|
|
webview: wv.clone(),
|
|
|
|
|
state: WarmthState::Warm,
|
|
|
|
|
cooling_until: None,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
info!("warmed webview {key}");
|
|
|
|
|
wv
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Walk an arbitrary layout's web/html cells and add their pool keys to `out`.
|
|
|
|
|
/// Mirrors `cell_keys` for cameras.
|
|
|
|
|
fn web_keys_for_layout(
|
|
|
|
|
layout: &crate::bundle::BundleLayout,
|
|
|
|
|
out: &mut std::collections::HashSet<WebKey>,
|
|
|
|
|
) {
|
|
|
|
|
for cell in &layout.cells {
|
|
|
|
|
match cell.content_type.as_str() {
|
|
|
|
|
"web" => {
|
|
|
|
|
if let Some(url) = cell.web_url.as_deref() {
|
|
|
|
|
let url = url.trim();
|
|
|
|
|
if !url.is_empty() {
|
|
|
|
|
out.insert(format!("web:{url}"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"html" => {
|
|
|
|
|
if let Some(html) = cell.html_content.as_deref() {
|
|
|
|
|
if !html.trim().is_empty() {
|
|
|
|
|
out.insert(html_key(html));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Apply hot/warm/cooling state to the WebView pool. Mirror of
|
|
|
|
|
/// `recompute_pool_states` for cameras.
|
|
|
|
|
fn recompute_web_states(
|
|
|
|
|
warm_set: &std::collections::HashSet<WebKey>,
|
|
|
|
|
hot_set: &std::collections::HashSet<WebKey>,
|
|
|
|
|
max_cooling_secs: u32,
|
|
|
|
|
) {
|
|
|
|
|
let mut to_remove: Vec<WebKey> = Vec::new();
|
|
|
|
|
WARM_WEBVIEWS.with(|w| {
|
|
|
|
|
let mut warm = w.borrow_mut();
|
|
|
|
|
for (key, entry) in warm.iter_mut() {
|
|
|
|
|
if hot_set.contains(key) {
|
|
|
|
|
entry.state = WarmthState::Hot;
|
|
|
|
|
entry.cooling_until = None;
|
|
|
|
|
} else if warm_set.contains(key) {
|
|
|
|
|
entry.state = WarmthState::Warm;
|
|
|
|
|
entry.cooling_until = None;
|
|
|
|
|
} else {
|
|
|
|
|
if entry.state == WarmthState::Cooling {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if max_cooling_secs == 0 {
|
|
|
|
|
to_remove.push(key.clone());
|
|
|
|
|
} else {
|
|
|
|
|
entry.state = WarmthState::Cooling;
|
|
|
|
|
entry.cooling_until = Some(
|
|
|
|
|
Instant::now() + Duration::from_secs(max_cooling_secs as u64),
|
|
|
|
|
);
|
|
|
|
|
info!("webview {key}: cooling for {max_cooling_secs}s before drop");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for k in &to_remove {
|
|
|
|
|
if let Some(e) = warm.remove(k) {
|
|
|
|
|
if e.webview.parent().is_some() { e.webview.unparent(); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drop Cooling webviews whose timer has expired.
|
|
|
|
|
fn expire_cooling_webviews() {
|
|
|
|
|
let now = Instant::now();
|
|
|
|
|
let mut expired: Vec<WebKey> = Vec::new();
|
|
|
|
|
WARM_WEBVIEWS.with(|w| {
|
|
|
|
|
let mut warm = w.borrow_mut();
|
|
|
|
|
let keys: Vec<WebKey> = warm
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|(_, e)| {
|
|
|
|
|
e.state == WarmthState::Cooling
|
|
|
|
|
&& e.cooling_until.is_some_and(|t| now >= t)
|
|
|
|
|
})
|
|
|
|
|
.map(|(k, _)| k.clone())
|
|
|
|
|
.collect();
|
|
|
|
|
for k in keys {
|
|
|
|
|
if let Some(e) = warm.remove(&k) {
|
|
|
|
|
if e.webview.parent().is_some() { e.webview.unparent(); }
|
|
|
|
|
expired.push(k);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
for key in expired {
|
|
|
|
|
info!("webview {key}: cooling expired → dropped");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 01:37:32 +00:00
|
|
|
/// Hide the mouse pointer on a window. Kiosks have no input device the user
|
|
|
|
|
/// should see — the cursor is just visual noise sitting in the middle of the
|
|
|
|
|
/// content. GDK's "none" cursor name maps to a hidden cursor on Wayland.
|
|
|
|
|
fn hide_cursor_on(window: &ApplicationWindow) {
|
|
|
|
|
if let Some(cursor) = gtk::gdk::Cursor::from_name("none", None) {
|
|
|
|
|
window.set_cursor(Some(&cursor));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 02:18:40 +00:00
|
|
|
fn show_logo(window: &ApplicationWindow) {
|
2026-05-13 01:39:03 +00:00
|
|
|
let vbox = GtkBox::new(Orientation::Vertical, 24);
|
2026-05-11 07:38:50 +00:00
|
|
|
vbox.set_valign(gtk::Align::Center);
|
|
|
|
|
vbox.set_halign(gtk::Align::Center);
|
|
|
|
|
vbox.set_vexpand(true);
|
|
|
|
|
vbox.set_hexpand(true);
|
|
|
|
|
vbox.append(&logo_picture(BETTERFRAME_LOGO_SVG, 480, 118, "idle-logo"));
|
2026-05-13 01:39:03 +00:00
|
|
|
vbox.append(&spinner(36));
|
2026-05-11 07:38:50 +00:00
|
|
|
window.set_child(Some(&vbox));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 02:04:03 +00:00
|
|
|
fn show_empty_display_reference(
|
|
|
|
|
window: &ApplicationWindow,
|
|
|
|
|
bundle: &KioskBundle,
|
|
|
|
|
display: &BundleDisplayWithLayouts,
|
|
|
|
|
) {
|
|
|
|
|
let overlay = gtk::Overlay::new();
|
|
|
|
|
overlay.set_vexpand(true);
|
|
|
|
|
overlay.set_hexpand(true);
|
|
|
|
|
|
|
|
|
|
let vbox = GtkBox::new(Orientation::Vertical, 24);
|
|
|
|
|
vbox.set_valign(gtk::Align::Center);
|
|
|
|
|
vbox.set_halign(gtk::Align::Center);
|
|
|
|
|
vbox.set_vexpand(true);
|
|
|
|
|
vbox.set_hexpand(true);
|
|
|
|
|
vbox.append(&logo_picture(BETTERFRAME_LOGO_SVG, 480, 118, "idle-logo"));
|
|
|
|
|
overlay.set_child(Some(&vbox));
|
|
|
|
|
|
|
|
|
|
let last_sync = CURRENT_SYNC_LABEL.with(|s| s.borrow().clone());
|
|
|
|
|
let info = Label::new(Some(&format!(
|
|
|
|
|
"Kiosk: {}\nDisplay: {}\nLast sync: {}",
|
|
|
|
|
bundle.kiosk_name, display.name, last_sync,
|
|
|
|
|
)));
|
|
|
|
|
info.set_halign(gtk::Align::Start);
|
|
|
|
|
info.set_valign(gtk::Align::End);
|
|
|
|
|
info.set_margin_start(24);
|
|
|
|
|
info.set_margin_bottom(20);
|
|
|
|
|
info.set_xalign(0.0);
|
|
|
|
|
add_css(
|
|
|
|
|
&info,
|
|
|
|
|
".empty-reference { color: #8a8a8a; font-size: 13px; font-family: monospace; }",
|
|
|
|
|
);
|
|
|
|
|
info.add_css_class("empty-reference");
|
|
|
|
|
overlay.add_overlay(&info);
|
|
|
|
|
|
|
|
|
|
window.set_child(Some(&overlay));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn format_current_local_time() -> String {
|
|
|
|
|
gtk::glib::DateTime::now_local()
|
|
|
|
|
.and_then(|dt| dt.format("%Y-%m-%d %H:%M:%S"))
|
|
|
|
|
.map(|s| s.to_string())
|
|
|
|
|
.unwrap_or_else(|_| "unknown".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 01:39:03 +00:00
|
|
|
/// A centered GTK spinner sized at `px` pixels. Already spinning.
|
|
|
|
|
fn spinner(px: i32) -> gtk::Spinner {
|
|
|
|
|
let s = gtk::Spinner::new();
|
|
|
|
|
s.set_size_request(px, px);
|
|
|
|
|
s.set_halign(gtk::Align::Center);
|
|
|
|
|
s.start();
|
|
|
|
|
s
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 07:38:50 +00:00
|
|
|
fn none_cell() -> gtk::Widget {
|
|
|
|
|
placeholder(None)
|
2026-05-10 02:18:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-11 07:38:50 +00:00
|
|
|
fn placeholder(text: Option<&str>) -> gtk::Widget {
|
|
|
|
|
let vbox = GtkBox::new(Orientation::Vertical, 8);
|
|
|
|
|
add_css(
|
|
|
|
|
&vbox,
|
|
|
|
|
".bf-placeholder { background-color: #111; } .bf-placeholder-text { color: #666; font-size: 14px; }",
|
|
|
|
|
);
|
|
|
|
|
vbox.add_css_class("bf-placeholder");
|
|
|
|
|
vbox.set_valign(gtk::Align::Center);
|
|
|
|
|
vbox.set_halign(gtk::Align::Center);
|
|
|
|
|
vbox.set_vexpand(true);
|
|
|
|
|
vbox.set_hexpand(true);
|
|
|
|
|
vbox.append(&logo_picture(BETTERFRAME_MARK_SVG, 56, 56, "cell-logo"));
|
|
|
|
|
if let Some(text) = text {
|
|
|
|
|
let label = Label::new(Some(text));
|
|
|
|
|
label.add_css_class("bf-placeholder-text");
|
|
|
|
|
vbox.append(&label);
|
|
|
|
|
}
|
|
|
|
|
vbox.upcast()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn logo_picture(svg: &'static str, width: i32, height: i32, css_class: &str) -> gtk::Widget {
|
|
|
|
|
let bytes = gtk::glib::Bytes::from_static(svg.as_bytes());
|
|
|
|
|
match gtk::gdk::Texture::from_bytes(&bytes) {
|
|
|
|
|
Ok(texture) => {
|
|
|
|
|
let picture = Picture::for_paintable(&texture);
|
|
|
|
|
picture.add_css_class(css_class);
|
|
|
|
|
picture.set_content_fit(gtk::ContentFit::Contain);
|
|
|
|
|
picture.set_can_shrink(true);
|
|
|
|
|
picture.set_size_request(width, height);
|
|
|
|
|
picture.set_valign(gtk::Align::Center);
|
|
|
|
|
picture.set_halign(gtk::Align::Center);
|
|
|
|
|
picture.upcast()
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
warn!("failed to load embedded logo: {err}");
|
|
|
|
|
let label = Label::new(Some("BetterFrame"));
|
|
|
|
|
label.set_size_request(width, height);
|
|
|
|
|
label.set_valign(gtk::Align::Center);
|
|
|
|
|
label.set_halign(gtk::Align::Center);
|
|
|
|
|
label.upcast()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-10 18:09:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn add_css(widget: &impl IsA<gtk::Widget>, css: &str) {
|
2026-05-10 02:18:40 +00:00
|
|
|
let provider = gtk::CssProvider::new();
|
2026-05-10 18:09:06 +00:00
|
|
|
provider.load_from_string(css);
|
2026-05-10 02:18:40 +00:00
|
|
|
gtk::style_context_add_provider_for_display(
|
2026-05-10 18:09:06 +00:00
|
|
|
&widget.display(),
|
2026-05-10 02:18:40 +00:00
|
|
|
&provider,
|
|
|
|
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
|
|
|
);
|
|
|
|
|
}
|