mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
feat: Rust kiosk app — GTK4 + GStreamer multi-camera display
- Server discovery (localhost → betterframe.local → cloud) - Pairing flow with fullscreen code display - Bundle fetch and layout rendering - GTK4 Grid layout matching template regions - GStreamer pipelines per camera cell via gtk4paintablesink - Heartbeat loop in background thread - Placeholder widgets for web/html cells
This commit is contained in:
parent
f5a2645ffc
commit
371c023c81
6 changed files with 692 additions and 0 deletions
30
kiosk/Cargo.toml
Normal file
30
kiosk/Cargo.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
[package]
|
||||||
|
name = "betterframe-kiosk"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "BetterFrame kiosk — multi-camera display with GTK4 + GStreamer"
|
||||||
|
license = "AGPL-3.0-only OR Commercial"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# GTK4 for windowing/layout
|
||||||
|
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||||
|
|
||||||
|
# GStreamer for RTSP decode
|
||||||
|
gstreamer = "0.23"
|
||||||
|
gstreamer-video = "0.23"
|
||||||
|
gst-plugin-gtk4 = "0.13"
|
||||||
|
|
||||||
|
# HTTP client for server API
|
||||||
|
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||||
|
|
||||||
|
# JSON
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs"] }
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
dirs = "6"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
107
kiosk/src/bundle.rs
Normal file
107
kiosk/src/bundle.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct KioskBundle {
|
||||||
|
pub kiosk_id: u32,
|
||||||
|
pub kiosk_name: String,
|
||||||
|
pub display: BundleDisplay,
|
||||||
|
pub layouts: Vec<BundleLayout>,
|
||||||
|
pub cameras: Vec<BundleCamera>,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BundleDisplay {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub width_px: u32,
|
||||||
|
pub height_px: u32,
|
||||||
|
pub idle_timeout_seconds: u32,
|
||||||
|
pub sleep_timeout_seconds: u32,
|
||||||
|
pub default_layout_id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BundleLayout {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub template: Option<BundleTemplate>,
|
||||||
|
pub priority: String,
|
||||||
|
pub cooling_timeout_seconds: Option<u32>,
|
||||||
|
pub preload_camera_ids: Vec<u32>,
|
||||||
|
pub is_default: bool,
|
||||||
|
pub resets_idle_timer: bool,
|
||||||
|
pub cells: Vec<BundleCell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BundleTemplate {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub regions: Vec<BundleRegion>,
|
||||||
|
pub grid_cols: u32,
|
||||||
|
pub grid_rows: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BundleRegion {
|
||||||
|
pub name: String,
|
||||||
|
pub row: u32,
|
||||||
|
pub col: u32,
|
||||||
|
#[serde(rename = "rowSpan")]
|
||||||
|
pub row_span: u32,
|
||||||
|
#[serde(rename = "colSpan")]
|
||||||
|
pub col_span: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BundleCell {
|
||||||
|
pub region_name: String,
|
||||||
|
pub content_type: String,
|
||||||
|
pub camera_id: Option<u32>,
|
||||||
|
pub stream_selector: Option<String>,
|
||||||
|
pub web_url: Option<String>,
|
||||||
|
pub html_content: Option<String>,
|
||||||
|
pub cooling_timeout_seconds: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BundleCamera {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub cam_type: String,
|
||||||
|
pub rtsp_url: Option<String>,
|
||||||
|
pub stream_policy: String,
|
||||||
|
pub streams: Vec<BundleStream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BundleStream {
|
||||||
|
pub id: u32,
|
||||||
|
pub role: String,
|
||||||
|
pub name: String,
|
||||||
|
pub rtsp_uri: String,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub encoding: Option<String>,
|
||||||
|
pub framerate: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BundleCamera {
|
||||||
|
/// Pick the best stream URI for this camera given a cell's stream_selector.
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(|s| s.rtsp_uri.as_str())
|
||||||
|
.or(self.rtsp_url.as_deref())
|
||||||
|
}
|
||||||
|
}
|
||||||
16
kiosk/src/main.rs
Normal file
16
kiosk/src/main.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
mod server;
|
||||||
|
mod bundle;
|
||||||
|
mod pipeline;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::from_default_env().add_directive("betterframe_kiosk=info".parse().unwrap()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
gstreamer::init().expect("Failed to init GStreamer");
|
||||||
|
let app = ui::build_app();
|
||||||
|
std::process::exit(app.run().into());
|
||||||
|
}
|
||||||
82
kiosk/src/pipeline.rs
Normal file
82
kiosk/src/pipeline.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
use gstreamer::prelude::*;
|
||||||
|
use gstreamer::{self as gst, Element, Pipeline};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// Create a GStreamer pipeline for an RTSP camera that outputs to a GTK4 paintable sink.
|
||||||
|
/// Returns (pipeline, paintable_sink) — the sink's "paintable" property drives a gtk4::Picture.
|
||||||
|
pub fn create_camera_pipeline(name: &str, rtsp_uri: &str) -> Option<(Pipeline, Element)> {
|
||||||
|
let pipeline_name = format!("cam-{name}");
|
||||||
|
let pipeline = Pipeline::with_name(&pipeline_name);
|
||||||
|
|
||||||
|
let src = gst::ElementFactory::make("rtspsrc")
|
||||||
|
.property("location", rtsp_uri)
|
||||||
|
.property("latency", 300u32)
|
||||||
|
.property_from_str("protocols", "tcp")
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let depay_h264 = gst::ElementFactory::make("rtph264depay").build().ok();
|
||||||
|
let depay_h265 = gst::ElementFactory::make("rtph265depay").build().ok();
|
||||||
|
let parse_h264 = gst::ElementFactory::make("h264parse").build().ok();
|
||||||
|
let parse_h265 = gst::ElementFactory::make("h265parse").build().ok();
|
||||||
|
let decode = gst::ElementFactory::make("avdec_h264").build()
|
||||||
|
.or_else(|| gst::ElementFactory::make("decodebin").build().ok());
|
||||||
|
|
||||||
|
let convert = gst::ElementFactory::make("videoconvert").build().ok()?;
|
||||||
|
|
||||||
|
let sink = gst::ElementFactory::make("gtk4paintablesink")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("gtk4paintablesink not available: {e}. Install gst-plugin-gtk4");
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let queue = gst::ElementFactory::make("queue")
|
||||||
|
.property("max-size-buffers", 1u32)
|
||||||
|
.property("leaky", 2u32) // downstream
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
pipeline.add_many([&src, &queue, &convert, &sink]).ok()?;
|
||||||
|
gst::Element::link_many([&queue, &convert, &sink]).ok()?;
|
||||||
|
|
||||||
|
// rtspsrc has dynamic pads — connect on pad-added
|
||||||
|
let queue_weak = queue.downgrade();
|
||||||
|
let pipeline_name_clone = pipeline_name.clone();
|
||||||
|
src.connect_pad_added(move |_src, pad| {
|
||||||
|
let caps = pad.current_caps().or_else(|| pad.query_caps(None));
|
||||||
|
let caps_str = caps.map(|c| c.to_string()).unwrap_or_default();
|
||||||
|
|
||||||
|
// Only link video pads
|
||||||
|
if !caps_str.contains("video") && !caps_str.contains("264") && !caps_str.contains("265") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("[{pipeline_name_clone}] linking pad: {caps_str}");
|
||||||
|
|
||||||
|
let Some(queue) = queue_weak.upgrade() else { return };
|
||||||
|
if pad.link(&queue.static_pad("sink").unwrap()).is_err() {
|
||||||
|
warn!("[{pipeline_name_clone}] pad link failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// For decodebin-style pipelines, we might need more complex linking.
|
||||||
|
// For now, rtspsrc → queue → convert → sink works for raw decode.
|
||||||
|
// The actual decode happens if we insert depay+parse+decoder elements.
|
||||||
|
// TODO: auto-detect codec and insert appropriate decoder chain.
|
||||||
|
|
||||||
|
info!("[{pipeline_name}] pipeline created for {rtsp_uri}");
|
||||||
|
Some((pipeline, sink))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a pipeline.
|
||||||
|
pub fn play(pipeline: &Pipeline) {
|
||||||
|
if let Err(e) = pipeline.set_state(gst::State::Playing) {
|
||||||
|
error!("Failed to set pipeline to Playing: {e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a pipeline.
|
||||||
|
pub fn stop(pipeline: &Pipeline) {
|
||||||
|
let _ = pipeline.set_state(gst::State::Null);
|
||||||
|
}
|
||||||
162
kiosk/src/server.rs
Normal file
162
kiosk/src/server.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::bundle::KioskBundle;
|
||||||
|
|
||||||
|
fn state_dir() -> PathBuf {
|
||||||
|
let home = dirs::home_dir().expect("no home directory");
|
||||||
|
let dir = home.join(".betterframe-kiosk");
|
||||||
|
fs::create_dir_all(&dir).ok();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_file() -> PathBuf { state_dir().join("kiosk.key") }
|
||||||
|
fn server_file() -> PathBuf { state_dir().join("server.url") }
|
||||||
|
|
||||||
|
/// Discover the BetterFrame server.
|
||||||
|
pub fn discover_server(override_url: Option<&str>) -> String {
|
||||||
|
if let Some(url) = override_url {
|
||||||
|
return url.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check saved
|
||||||
|
if let Ok(saved) = fs::read_to_string(server_file()) {
|
||||||
|
let saved = saved.trim().to_string();
|
||||||
|
if check_health(&saved) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = [
|
||||||
|
"http://localhost:18081",
|
||||||
|
"http://betterframe.local:18081",
|
||||||
|
"https://frame.betterportal.cloud",
|
||||||
|
];
|
||||||
|
|
||||||
|
for url in candidates {
|
||||||
|
info!("trying {url}...");
|
||||||
|
if check_health(url) {
|
||||||
|
fs::write(server_file(), url).ok();
|
||||||
|
return url.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic!("Could not find BetterFrame server");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_health(url: &str) -> bool {
|
||||||
|
reqwest::blocking::Client::new()
|
||||||
|
.get(format!("{url}/healthz"))
|
||||||
|
.timeout(Duration::from_secs(3))
|
||||||
|
.send()
|
||||||
|
.map(|r| r.status().is_success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if already paired (key file exists).
|
||||||
|
pub fn is_paired() -> bool {
|
||||||
|
key_file().exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read stored kiosk key.
|
||||||
|
pub fn load_key() -> String {
|
||||||
|
fs::read_to_string(key_file())
|
||||||
|
.expect("failed to read kiosk key")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct InitiateResp {
|
||||||
|
code: String,
|
||||||
|
expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initiate pairing — returns (code, expires_at).
|
||||||
|
pub fn initiate_pairing(server: &str) -> (String, String) {
|
||||||
|
let hostname = hostname::get()
|
||||||
|
.map(|h| h.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|_| "kiosk".into());
|
||||||
|
|
||||||
|
let hw_model = fs::read_to_string("/proc/device-tree/model")
|
||||||
|
.unwrap_or_else(|_| "unknown".into())
|
||||||
|
.replace('\0', "");
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp: InitiateResp = client
|
||||||
|
.post(format!("{server}/api/pair/initiate"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"proposed_name": hostname,
|
||||||
|
"hardware_model": hw_model,
|
||||||
|
"capabilities": ["rtsp", "gstreamer", "gtk4"]
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.expect("pairing initiate failed")
|
||||||
|
.json()
|
||||||
|
.expect("bad initiate response");
|
||||||
|
|
||||||
|
(resp.code, resp.expires_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ClaimResp {
|
||||||
|
status: String,
|
||||||
|
kiosk_key: Option<String>,
|
||||||
|
kiosk_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll for pairing claim. Returns (name, key) when admin confirms.
|
||||||
|
pub fn poll_claim(server: &str, code: &str) -> (String, String) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
loop {
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{server}/api/pair/claim"))
|
||||||
|
.json(&serde_json::json!({ "code": code }))
|
||||||
|
.send()
|
||||||
|
.expect("claim request failed");
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 200 {
|
||||||
|
let claim: ClaimResp = resp.json().expect("bad claim response");
|
||||||
|
if claim.status == "claimed" {
|
||||||
|
let key = claim.kiosk_key.expect("missing kiosk_key");
|
||||||
|
let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into());
|
||||||
|
fs::write(key_file(), &key).expect("failed to save kiosk key");
|
||||||
|
return (name, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch bundle from server.
|
||||||
|
pub fn fetch_bundle(server: &str, key: &str) -> KioskBundle {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{server}/api/kiosk/bundle"))
|
||||||
|
.header("Authorization", format!("Bearer {key}"))
|
||||||
|
.send()
|
||||||
|
.expect("bundle fetch failed");
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
panic!("Bundle fetch returned {}", resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.json().expect("bad bundle JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send heartbeat.
|
||||||
|
pub fn heartbeat(server: &str, key: &str) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let _ = client
|
||||||
|
.post(format!("{server}/api/kiosk/heartbeat"))
|
||||||
|
.header("Authorization", format!("Bearer {key}"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"kiosk_app_version": env!("CARGO_PKG_VERSION"),
|
||||||
|
}))
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.send();
|
||||||
|
}
|
||||||
295
kiosk/src/ui.rs
Normal file
295
kiosk/src/ui.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture};
|
||||||
|
use gstreamer::prelude::*;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::bundle::{BundleLayout, KioskBundle};
|
||||||
|
use crate::pipeline;
|
||||||
|
use crate::server;
|
||||||
|
|
||||||
|
const APP_ID: &str = "dev.betterframe.kiosk";
|
||||||
|
|
||||||
|
pub fn build_app() -> Application {
|
||||||
|
let app = Application::builder().application_id(APP_ID).build();
|
||||||
|
app.connect_activate(activate);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(app: &Application) {
|
||||||
|
let window = ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
|
.title("BetterFrame")
|
||||||
|
.fullscreened(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Dark background
|
||||||
|
let provider = gtk::CssProvider::new();
|
||||||
|
provider.load_from_string("window { background-color: #1a1a2e; }");
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&window.display(),
|
||||||
|
&provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
let container = GtkBox::new(Orientation::Vertical, 0);
|
||||||
|
container.set_vexpand(true);
|
||||||
|
container.set_hexpand(true);
|
||||||
|
window.set_child(Some(&container));
|
||||||
|
|
||||||
|
// Show pairing or camera display
|
||||||
|
let server_url = std::env::args().nth(1);
|
||||||
|
let container_clone = container.clone();
|
||||||
|
let window_clone = window.clone();
|
||||||
|
|
||||||
|
// Run server interaction in a thread, update UI via idle_add
|
||||||
|
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})");
|
||||||
|
|
||||||
|
// Show pairing code on UI
|
||||||
|
let code_clone = code.clone();
|
||||||
|
gtk::glib::idle_add_once(move || {
|
||||||
|
show_pairing_code(&container_clone, &code_clone);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (name, key) = server::poll_claim(&server, &code);
|
||||||
|
info!("paired as: {name}");
|
||||||
|
key
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch bundle
|
||||||
|
let bundle = server::fetch_bundle(&server, &key);
|
||||||
|
info!("bundle: {} cameras, {} layouts", bundle.cameras.len(), bundle.layouts.len());
|
||||||
|
|
||||||
|
// Render layout on UI thread
|
||||||
|
let server_clone = server.clone();
|
||||||
|
let key_clone = key.clone();
|
||||||
|
gtk::glib::idle_add_once(move || {
|
||||||
|
render_bundle(&window_clone, bundle);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat loop
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||||
|
server::heartbeat(&server_clone, &key_clone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_pairing_code(container: &GtkBox, code: &str) {
|
||||||
|
// Clear existing children
|
||||||
|
while let Some(child) = container.first_child() {
|
||||||
|
container.remove(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vbox = GtkBox::new(Orientation::Vertical, 20);
|
||||||
|
vbox.set_valign(gtk::Align::Center);
|
||||||
|
vbox.set_halign(gtk::Align::Center);
|
||||||
|
vbox.set_vexpand(true);
|
||||||
|
|
||||||
|
let title = Label::new(Some("BetterFrame"));
|
||||||
|
title.add_css_class("title");
|
||||||
|
let title_provider = gtk::CssProvider::new();
|
||||||
|
title_provider.load_from_string(".title { font-size: 24px; color: #888; font-weight: 300; }");
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&title.display(),
|
||||||
|
&title_provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
let code_label = Label::new(Some(code));
|
||||||
|
code_label.add_css_class("pairing-code");
|
||||||
|
let code_provider = gtk::CssProvider::new();
|
||||||
|
code_provider.load_from_string(
|
||||||
|
".pairing-code { font-size: 72px; color: #fff; font-weight: 700; letter-spacing: 12px; font-family: monospace; }",
|
||||||
|
);
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&code_label.display(),
|
||||||
|
&code_provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
let hint = Label::new(Some("Enter this code in BetterFrame admin to pair"));
|
||||||
|
hint.add_css_class("hint");
|
||||||
|
let hint_provider = gtk::CssProvider::new();
|
||||||
|
hint_provider.load_from_string(".hint { font-size: 14px; color: #666; }");
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&hint.display(),
|
||||||
|
&hint_provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
vbox.append(&title);
|
||||||
|
vbox.append(&code_label);
|
||||||
|
vbox.append(&hint);
|
||||||
|
container.append(&vbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
|
||||||
|
// Find default layout
|
||||||
|
let layout = bundle.layouts.iter()
|
||||||
|
.find(|l| l.is_default)
|
||||||
|
.or_else(|| bundle.layouts.first());
|
||||||
|
|
||||||
|
let Some(layout) = layout else {
|
||||||
|
warn!("no layouts in bundle");
|
||||||
|
show_logo(window);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ref template) = layout.template else {
|
||||||
|
warn!("layout has no template");
|
||||||
|
show_logo(window);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("rendering layout '{}' with {}x{} grid, {} cells",
|
||||||
|
layout.name, template.grid_cols, template.grid_rows, layout.cells.len());
|
||||||
|
|
||||||
|
let grid = Grid::new();
|
||||||
|
grid.set_row_homogeneous(true);
|
||||||
|
grid.set_column_homogeneous(true);
|
||||||
|
grid.set_vexpand(true);
|
||||||
|
grid.set_hexpand(true);
|
||||||
|
|
||||||
|
// Map cameras by ID for quick lookup
|
||||||
|
let cam_map: std::collections::HashMap<u32, &crate::bundle::BundleCamera> =
|
||||||
|
bundle.cameras.iter().map(|c| (c.id, c)).collect();
|
||||||
|
|
||||||
|
let pipelines: Rc<RefCell<Vec<gstreamer::Pipeline>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
|
||||||
|
for cell in &layout.cells {
|
||||||
|
// Find region in template
|
||||||
|
let region = template.regions.iter().find(|r| r.name == cell.region_name);
|
||||||
|
let Some(region) = region else {
|
||||||
|
warn!("region '{}' not found in template", cell.region_name);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if let Some(uri) = cam.stream_uri(cell.stream_selector.as_deref()) {
|
||||||
|
match pipeline::create_camera_pipeline(&cam.name, uri) {
|
||||||
|
Some((pipe, sink)) => {
|
||||||
|
let paintable = sink.property::<gtk::gdk::Paintable>("paintable");
|
||||||
|
let picture = Picture::for_paintable(&paintable);
|
||||||
|
picture.set_content_fit(gtk::ContentFit::Cover);
|
||||||
|
picture.set_vexpand(true);
|
||||||
|
picture.set_hexpand(true);
|
||||||
|
pipeline::play(&pipe);
|
||||||
|
pipelines.borrow_mut().push(pipe);
|
||||||
|
picture.upcast()
|
||||||
|
}
|
||||||
|
None => placeholder_label(&format!("{} (pipeline error)", cam.name)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholder_label(&format!("{} (no stream)", cam.name))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholder_label("Unknown camera")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholder_label("No camera assigned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"html" => {
|
||||||
|
let html = cell.html_content.as_deref().unwrap_or("");
|
||||||
|
// For HTML cells, show a label placeholder (WebKit integration later)
|
||||||
|
let label = Label::new(Some("HTML Content"));
|
||||||
|
label.set_markup(&format!("<span color='#888'>{}</span>",
|
||||||
|
gtk::glib::markup_escape_text(html).chars().take(100).collect::<String>()));
|
||||||
|
label.set_vexpand(true);
|
||||||
|
label.set_hexpand(true);
|
||||||
|
label.upcast()
|
||||||
|
}
|
||||||
|
"web" => {
|
||||||
|
let url = cell.web_url.as_deref().unwrap_or("about:blank");
|
||||||
|
placeholder_label(&format!("Web: {url}"))
|
||||||
|
}
|
||||||
|
_ => placeholder_label("Unknown content"),
|
||||||
|
};
|
||||||
|
|
||||||
|
grid.attach(
|
||||||
|
&widget,
|
||||||
|
region.col as i32,
|
||||||
|
region.row as i32,
|
||||||
|
region.col_span as i32,
|
||||||
|
region.row_span as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill empty regions with dark placeholders
|
||||||
|
for region in &template.regions {
|
||||||
|
if !layout.cells.iter().any(|c| c.region_name == region.name) {
|
||||||
|
let empty = GtkBox::new(Orientation::Vertical, 0);
|
||||||
|
let empty_provider = gtk::CssProvider::new();
|
||||||
|
empty_provider.load_from_string("box { background-color: #111; }");
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&empty.display(),
|
||||||
|
&empty_provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
empty.set_vexpand(true);
|
||||||
|
empty.set_hexpand(true);
|
||||||
|
grid.attach(
|
||||||
|
&empty,
|
||||||
|
region.col as i32,
|
||||||
|
region.row as i32,
|
||||||
|
region.col_span as i32,
|
||||||
|
region.row_span as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.set_child(Some(&grid));
|
||||||
|
|
||||||
|
// Store pipelines so they don't get dropped
|
||||||
|
window.connect_destroy(move |_| {
|
||||||
|
for pipe in pipelines.borrow().iter() {
|
||||||
|
pipeline::stop(pipe);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_logo(window: &ApplicationWindow) {
|
||||||
|
let label = Label::new(Some("BetterFrame"));
|
||||||
|
let provider = gtk::CssProvider::new();
|
||||||
|
provider.load_from_string("label { font-size: 48px; color: #fff; font-weight: 300; }");
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&label.display(),
|
||||||
|
&provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
label.set_valign(gtk::Align::Center);
|
||||||
|
label.set_halign(gtk::Align::Center);
|
||||||
|
label.set_vexpand(true);
|
||||||
|
window.set_child(Some(&label));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder_label(text: &str) -> gtk::Widget {
|
||||||
|
let label = Label::new(Some(text));
|
||||||
|
label.set_vexpand(true);
|
||||||
|
label.set_hexpand(true);
|
||||||
|
let provider = gtk::CssProvider::new();
|
||||||
|
provider.load_from_string("label { color: #666; font-size: 14px; background-color: #111; }");
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&label.display(),
|
||||||
|
&provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
label.upcast()
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue