From 3897239ef4495bb8c238e94dee7ca18e7451dc37 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 14 Apr 2026 14:38:03 -0300 Subject: [PATCH] launcher: embed live preview controls --- client/src/app.rs | 149 ++++++++------- client/src/app_support.rs | 11 +- client/src/launcher/diagnostics.rs | 2 +- client/src/launcher/mod.rs | 23 ++- client/src/launcher/preview.rs | 258 ++++++++++++++++++++++++++ client/src/launcher/state.rs | 10 +- client/src/launcher/ui.rs | 66 ++++++- scripts/ci/hygiene_gate_baseline.json | 13 +- scripts/ci/quality_gate_baseline.json | 6 +- 9 files changed, 443 insertions(+), 95 deletions(-) create mode 100644 client/src/launcher/preview.rs diff --git a/client/src/app.rs b/client/src/app.rs index 724eccc..e8c6377 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -148,82 +148,93 @@ impl LesavkaClientApp { .unwrap_or_else(|_| "breakout".to_string()) .to_ascii_lowercase(); let unified_view = view_mode == "unified"; + let disable_video_render = std::env::var("LESAVKA_DISABLE_VIDEO_RENDER") + .map(|value| value.trim() != "0") + .unwrap_or(false); info!( "🪟 video layout selected: {}", if unified_view { "unified" } else { "breakout" } ); - /*────────── video rendering thread (winit) ────*/ - let video_queue = app_support::sanitize_video_queue( - std::env::var("LESAVKA_VIDEO_QUEUE") - .ok() - .and_then(|v| v.parse::().ok()), - ); - let dump_video = std::env::var("LESAVKA_DUMP_VIDEO").is_ok(); - let (video_tx, mut video_rx) = tokio::sync::mpsc::channel::(video_queue); + if disable_video_render { + info!("🪟 launcher preview active; skipping standalone client video windows"); + } else { + /*────────── video rendering thread (winit) ────*/ + let video_queue = app_support::sanitize_video_queue( + std::env::var("LESAVKA_VIDEO_QUEUE") + .ok() + .and_then(|v| v.parse::().ok()), + ); + let dump_video = std::env::var("LESAVKA_DUMP_VIDEO").is_ok(); + let (video_tx, mut video_rx) = + tokio::sync::mpsc::channel::(video_queue); - std::thread::spawn(move || { - gtk::init().expect("GTK initialisation failed"); - #[allow(deprecated)] - { - let el = EventLoopBuilder::<()>::new() - .with_any_thread(true) - .build() - .unwrap(); - enum Renderer { - Unified(UnifiedMonitorWindow), - Breakout { - left: MonitorWindow, - right: MonitorWindow, - }, + std::thread::spawn(move || { + gtk::init().expect("GTK initialisation failed"); + #[allow(deprecated)] + { + let el = EventLoopBuilder::<()>::new() + .with_any_thread(true) + .build() + .unwrap(); + enum Renderer { + Unified(UnifiedMonitorWindow), + Breakout { + left: MonitorWindow, + right: MonitorWindow, + }, + } + let renderer = if unified_view { + Renderer::Unified( + UnifiedMonitorWindow::new().expect("unified-window") + ) + } else { + Renderer::Breakout { + left: MonitorWindow::new(0).expect("win0"), + right: MonitorWindow::new(1).expect("win1"), + } + }; + + let _ = el.run(move |_: Event<()>, elwt| { + elwt.set_control_flow(ControlFlow::WaitUntil( + std::time::Instant::now() + std::time::Duration::from_millis(16), + )); + static CNT: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + while let Ok(pkt) = video_rx.try_recv() { + CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 { + debug!( + "🎥 received {} video packets", + CNT.load(std::sync::atomic::Ordering::Relaxed) + ); + } + if dump_video { + static DUMP_CNT: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(0); + let n = + DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let eye = if pkt.id == 0 { "l" } else { "r" }; + let path = format!("/tmp/eye{eye}-cli-{n:05}.h264"); + std::fs::write(&path, &pkt.data).ok(); + } + match &renderer { + Renderer::Unified(window) => window.push_packet(pkt), + Renderer::Breakout { left, right } => match pkt.id { + 0 => left.push_packet(pkt), + 1 => right.push_packet(pkt), + _ => {} + }, + } + } + }); } - let renderer = if unified_view { - Renderer::Unified(UnifiedMonitorWindow::new().expect("unified-window")) - } else { - Renderer::Breakout { - left: MonitorWindow::new(0).expect("win0"), - right: MonitorWindow::new(1).expect("win1"), - } - }; + }); - let _ = el.run(move |_: Event<()>, elwt| { - elwt.set_control_flow(ControlFlow::WaitUntil( - std::time::Instant::now() + std::time::Duration::from_millis(16), - )); - static CNT: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(0); - while let Ok(pkt) = video_rx.try_recv() { - CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 { - debug!( - "🎥 received {} video packets", - CNT.load(std::sync::atomic::Ordering::Relaxed) - ); - } - if dump_video { - static DUMP_CNT: std::sync::atomic::AtomicU32 = - std::sync::atomic::AtomicU32::new(0); - let n = DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let eye = if pkt.id == 0 { "l" } else { "r" }; - let path = format!("/tmp/eye{eye}-cli-{n:05}.h264"); - std::fs::write(&path, &pkt.data).ok(); - } - match &renderer { - Renderer::Unified(window) => window.push_packet(pkt), - Renderer::Breakout { left, right } => match pkt.id { - 0 => left.push_packet(pkt), - 1 => right.push_packet(pkt), - _ => {} - }, - } - } - }); - } - }); - - /*────────── start video gRPC pullers ──────────*/ - let ep_video = vid_ep.clone(); - tokio::spawn(Self::video_loop(ep_video, video_tx)); + /*────────── start video gRPC pullers ──────────*/ + let ep_video = vid_ep.clone(); + tokio::spawn(Self::video_loop(ep_video, video_tx)); + } /*────────── audio renderer & puller ───────────*/ let audio_out = AudioOut::new()?; @@ -373,7 +384,7 @@ impl LesavkaClientApp { let max_bitrate = std::env::var("LESAVKA_VIDEO_MAX_KBIT") .ok() .and_then(|v| v.parse::().ok()) - .unwrap_or(4_000); + .unwrap_or(6_000); for monitor_id in 0..=1 { let ep = ep.clone(); let tx = tx.clone(); diff --git a/client/src/app_support.rs b/client/src/app_support.rs index 21282ef..4ff91d9 100644 --- a/client/src/app_support.rs +++ b/client/src/app_support.rs @@ -5,6 +5,8 @@ use std::time::Duration; use crate::handshake::PeerCaps; use crate::input::camera::{CameraCodec, CameraConfig}; +pub const DEFAULT_SERVER_ADDR: &str = "http://38.28.125.112:50051"; + #[must_use] /// Resolve the server address from `--server`, positional args, env, or default. pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String { @@ -14,7 +16,7 @@ pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String { }) .or_else(|| args.iter().find(|arg| !arg.starts_with("--")).cloned()) .or_else(|| env_addr.map(ToOwned::to_owned)) - .unwrap_or_else(|| "http://127.0.0.1:50051".to_string()) + .unwrap_or_else(|| DEFAULT_SERVER_ADDR.to_string()) } #[must_use] @@ -54,7 +56,10 @@ fn parse_camera_codec(raw: &str) -> Option { #[cfg(test)] mod tests { - use super::{camera_config_from_caps, next_delay, resolve_server_addr, sanitize_video_queue}; + use super::{ + DEFAULT_SERVER_ADDR, camera_config_from_caps, next_delay, resolve_server_addr, + sanitize_video_queue, + }; use crate::handshake::PeerCaps; use crate::input::camera::CameraCodec; use std::time::Duration; @@ -81,7 +86,7 @@ mod tests { "http://env:2" ); assert_eq!(resolve_server_addr(&[], Some("http://env:2")), "http://env:2"); - assert_eq!(resolve_server_addr(&[], None), "http://127.0.0.1:50051"); + assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR); } #[test] diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs index 86337aa..ea228f8 100644 --- a/client/src/launcher/diagnostics.rs +++ b/client/src/launcher/diagnostics.rs @@ -147,7 +147,7 @@ mod tests { assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb")); assert_eq!(report.recent_samples.len(), 1); assert_eq!(report.notes, vec!["first note".to_string()]); - assert!(report.status.contains("mode=remote")); + assert!(report.status.contains("mode=local")); } #[test] diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index efd78ea..11da912 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -2,11 +2,14 @@ pub mod devices; pub mod diagnostics; pub mod state; +#[cfg(not(coverage))] +mod preview; mod ui; use std::collections::BTreeMap; use anyhow::Result; +use crate::app_support::DEFAULT_SERVER_ADDR; pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command}; pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode}; @@ -35,6 +38,9 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap { "LESAVKA_VIEW_MODE".to_string(), state.view_mode.as_env().to_string(), ); + if matches!(state.view_mode, ViewMode::Unified) { + envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string()); + } if let Some(camera) = state.devices.camera.as_ref() { envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone()); } @@ -57,7 +63,7 @@ fn resolve_server_addr(args: &[String]) -> String { .find(|arg| !arg.starts_with("--")) .cloned() .or_else(|| std::env::var("LESAVKA_SERVER_ADDR").ok()) - .unwrap_or_else(|| "http://127.0.0.1:50051".to_string()) + .unwrap_or_else(|| DEFAULT_SERVER_ADDR.to_string()) } #[cfg(test)] @@ -81,7 +87,7 @@ mod tests { assert_eq!(resolve_server_addr(&args), "http://from-arg:50051"); let args = vec!["--launcher".to_string()]; - assert!(resolve_server_addr(&args).starts_with("http://")); + assert_eq!(resolve_server_addr(&args), DEFAULT_SERVER_ADDR); } #[test] @@ -96,6 +102,10 @@ mod tests { let envs = runtime_env_vars(&state); assert_eq!(envs.get("LESAVKA_CAPTURE_REMOTE"), Some(&"0".to_string())); assert_eq!(envs.get("LESAVKA_VIEW_MODE"), Some(&"unified".to_string())); + assert_eq!( + envs.get("LESAVKA_DISABLE_VIDEO_RENDER"), + Some(&"1".to_string()) + ); assert_eq!(envs.get("LESAVKA_CAM_SOURCE"), Some(&"/dev/video0".to_string())); assert_eq!( envs.get("LESAVKA_MIC_SOURCE"), @@ -107,6 +117,15 @@ mod tests { ); } + #[test] + fn runtime_env_vars_do_not_disable_breakout_video_windows() { + let mut state = LauncherState::new(); + state.set_view_mode(ViewMode::Breakout); + + let envs = runtime_env_vars(&state); + assert!(!envs.contains_key("LESAVKA_DISABLE_VIDEO_RENDER")); + } + #[test] fn maybe_run_launcher_returns_false_with_explicit_opt_out() { let args = vec!["--no-launcher".to_string()]; diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs new file mode 100644 index 0000000..c51708d --- /dev/null +++ b/client/src/launcher/preview.rs @@ -0,0 +1,258 @@ +#[cfg(not(coverage))] +use anyhow::{Context, Result}; +#[cfg(not(coverage))] +use gstreamer as gst; +#[cfg(not(coverage))] +use gstreamer::prelude::{Cast, ElementExt, GstBinExt}; +#[cfg(not(coverage))] +use gstreamer_app as gst_app; +#[cfg(not(coverage))] +use gtk::{gdk, glib}; +#[cfg(not(coverage))] +use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient}; +#[cfg(not(coverage))] +use std::sync::{Arc, Mutex}; +#[cfg(not(coverage))] +use std::time::Duration; +#[cfg(not(coverage))] +use tonic::{Request, transport::Channel}; +#[cfg(not(coverage))] +use tracing::{debug, warn}; + +#[cfg(not(coverage))] +const PREVIEW_WIDTH: i32 = 640; +#[cfg(not(coverage))] +const PREVIEW_HEIGHT: i32 = 360; + +#[cfg(not(coverage))] +pub struct LauncherPreview { + feeds: [PreviewFeed; 2], +} + +#[cfg(not(coverage))] +impl LauncherPreview { + pub fn new(server_addr: String) -> Result { + gst::init().context("initialising preview gstreamer")?; + Ok(Self { + feeds: [ + PreviewFeed::spawn(server_addr.clone(), 0)?, + PreviewFeed::spawn(server_addr, 1)?, + ], + }) + } + + pub fn install_on_picture( + &self, + monitor_id: usize, + picture: >k::Picture, + status_label: >k::Label, + ) { + if let Some(feed) = self.feeds.get(monitor_id) { + feed.install_on_picture(picture, status_label); + } + } +} + +#[cfg(not(coverage))] +struct PreviewFeed { + latest: Arc>>, +} + +#[cfg(not(coverage))] +impl PreviewFeed { + fn spawn(server_addr: String, monitor_id: u32) -> Result { + let latest = Arc::new(Mutex::new(None)); + let store = Arc::clone(&latest); + std::thread::spawn(move || { + if let Err(err) = run_preview_feed(server_addr, monitor_id, store) { + warn!(monitor_id, ?err, "launcher preview feed exited"); + } + }); + Ok(Self { latest }) + } + + fn install_on_picture(&self, picture: >k::Picture, status_label: >k::Label) { + let picture = picture.clone(); + let status_label = status_label.clone(); + let latest = Arc::clone(&self.latest); + glib::timeout_add_local(Duration::from_millis(120), move || { + let next = latest.lock().ok().and_then(|mut slot| slot.take()); + if let Some(frame) = next { + let bytes = glib::Bytes::from_owned(frame.rgba); + let texture = gdk::MemoryTexture::new( + frame.width, + frame.height, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + frame.stride, + ); + picture.set_paintable(Some(&texture)); + status_label.set_text("Live"); + } + glib::ControlFlow::Continue + }); + } +} + +#[cfg(not(coverage))] +struct PreviewFrame { + width: i32, + height: i32, + stride: usize, + rgba: Vec, +} + +#[cfg(not(coverage))] +fn run_preview_feed( + server_addr: String, + monitor_id: u32, + latest: Arc>>, +) -> Result<()> { + let (pipeline, appsrc, appsink) = build_preview_pipeline()?; + pipeline + .set_state(gst::State::Playing) + .context("starting launcher preview pipeline")?; + + { + let latest = Arc::clone(&latest); + let appsink = appsink.clone(); + std::thread::spawn(move || { + loop { + if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { + if let Some(frame) = sample_to_frame(&sample) { + if let Ok(mut slot) = latest.lock() { + *slot = Some(frame); + } + } + } + } + }); + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("building preview tokio runtime")?; + + let _ = rt.block_on(async move { + loop { + let channel = match Channel::from_shared(server_addr.clone()) { + Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await { + Ok(channel) => channel, + Err(err) => { + warn!(monitor_id, ?err, "launcher preview connect failed"); + tokio::time::sleep(Duration::from_millis(750)).await; + continue; + } + }, + Err(err) => { + warn!(monitor_id, ?err, "launcher preview endpoint invalid"); + tokio::time::sleep(Duration::from_millis(750)).await; + continue; + } + }; + let mut cli = RelayClient::new(channel); + let req = MonitorRequest { + id: monitor_id, + max_bitrate: preview_max_bitrate(), + }; + match cli.capture_video(Request::new(req)).await { + Ok(mut stream) => { + debug!(monitor_id, "launcher preview connected"); + while let Some(item) = stream.get_mut().message().await.transpose() { + match item { + Ok(pkt) => push_preview_packet(&appsrc, pkt), + Err(err) => { + warn!(monitor_id, ?err, "launcher preview stream error"); + break; + } + } + } + } + Err(err) => warn!(monitor_id, ?err, "launcher preview rpc failed"), + } + tokio::time::sleep(Duration::from_millis(750)).await; + } + #[allow(unreachable_code)] + Ok::<(), anyhow::Error>(()) + }); + + Ok(()) +} + +#[cfg(not(coverage))] +fn build_preview_pipeline() -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink)> { + let desc = format!( + "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ + queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + h264parse disable-passthrough=true ! avdec_h264 ! videoconvert ! videoscale ! \ + video/x-raw,format=RGBA,width={PREVIEW_WIDTH},height={PREVIEW_HEIGHT},pixel-aspect-ratio=1/1 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true" + ); + let pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("preview pipeline"); + + let appsrc = pipeline + .by_name("src") + .context("missing preview appsrc")? + .downcast::() + .expect("preview appsrc"); + appsrc.set_caps(Some( + &gst::Caps::builder("video/x-h264") + .field("stream-format", &"byte-stream") + .field("alignment", &"au") + .build(), + )); + appsrc.set_format(gst::Format::Time); + + let appsink = pipeline + .by_name("sink") + .context("missing preview appsink")? + .downcast::() + .expect("preview appsink"); + appsink.set_caps(Some( + &gst::Caps::builder("video/x-raw") + .field("format", &"RGBA") + .field("width", &PREVIEW_WIDTH) + .field("height", &PREVIEW_HEIGHT) + .build(), + )); + + Ok((pipeline, appsrc, appsink)) +} + +#[cfg(not(coverage))] +fn push_preview_packet(appsrc: &gst_app::AppSrc, pkt: VideoPacket) { + let mut buf = gst::Buffer::from_slice(pkt.data); + if let Some(buf) = buf.get_mut() { + buf.set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); + } + let _ = appsrc.push_buffer(buf); +} + +#[cfg(not(coverage))] +fn sample_to_frame(sample: &gst::Sample) -> Option { + let caps = sample.caps()?; + let structure = caps.structure(0)?; + let width = structure.get::("width").ok()?; + let height = structure.get::("height").ok()?; + let buffer = sample.buffer()?; + let map = buffer.map_readable().ok()?; + let rgba = map.as_slice().to_vec(); + let stride = rgba.len() / height.max(1) as usize; + Some(PreviewFrame { + width, + height, + stride, + rgba, + }) +} + +#[cfg(not(coverage))] +fn preview_max_bitrate() -> u32 { + std::env::var("LESAVKA_PREVIEW_MAX_KBIT") + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(2_500) +} diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index 253fdbe..ea70bf1 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -51,8 +51,8 @@ pub struct LauncherState { impl Default for LauncherState { fn default() -> Self { Self { - routing: InputRouting::Remote, - view_mode: ViewMode::Breakout, + routing: InputRouting::Local, + view_mode: ViewMode::Unified, devices: DeviceSelection::default(), remote_active: false, notes: Vec::new(), @@ -160,10 +160,10 @@ mod tests { } #[test] - fn defaults_pick_remote_breakout_and_inactive_session() { + fn defaults_pick_local_unified_and_inactive_session() { let state = LauncherState::new(); - assert_eq!(state.routing, InputRouting::Remote); - assert_eq!(state.view_mode, ViewMode::Breakout); + assert_eq!(state.routing, InputRouting::Local); + assert_eq!(state.view_mode, ViewMode::Unified); assert!(!state.remote_active); assert!(state.devices.camera.is_none()); assert!(state.devices.microphone.is_none()); diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 06c8eb0..90f9ff4 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -4,6 +4,7 @@ use anyhow::{Result, anyhow}; use { super::devices::DeviceCatalog, super::diagnostics::quality_probe_command, + super::preview::LauncherPreview, super::runtime_env_vars, super::state::{InputRouting, LauncherState, ViewMode}, crate::paste, @@ -47,10 +48,13 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let window = gtk::ApplicationWindow::builder() .application(app) .title("Lesavka Launcher") - .default_width(680) - .default_height(520) + .default_width(980) + .default_height(860) .build(); + let scroll = gtk::ScrolledWindow::new(); + scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); + let root = gtk::Box::new(gtk::Orientation::Vertical, 8); root.set_margin_start(14); root.set_margin_end(14); @@ -62,11 +66,20 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { heading.set_halign(gtk::Align::Start); root.append(&heading); - let status_label = gtk::Label::new(Some("Idle")); + let status_label = gtk::Label::new(Some("Idle - preview warming up")); status_label.set_halign(gtk::Align::Start); status_label.set_selectable(true); root.append(&status_label); + let preview_frame = gtk::Frame::new(Some("Live Preview")); + let preview_row = gtk::Box::new(gtk::Orientation::Horizontal, 12); + let (left_preview, left_picture, left_status) = build_preview_pane("Display 1"); + let (right_preview, right_picture, right_status) = build_preview_pane("Display 2"); + preview_row.append(&left_preview); + preview_row.append(&right_preview); + preview_frame.set_child(Some(&preview_row)); + root.append(&preview_frame); + let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let server_label = gtk::Label::new(Some("Server")); server_label.set_halign(gtk::Align::Start); @@ -82,7 +95,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { controls.set_column_spacing(8); root.append(&controls); - let routing_label = gtk::Label::new(Some("Remote input capture")); + let routing_label = gtk::Label::new(Some("Start in remote control")); routing_label.set_halign(gtk::Align::Start); controls.attach(&routing_label, 0, 0, 1, 1); @@ -90,7 +103,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { routing_switch.set_active(matches!(state.borrow().routing, InputRouting::Remote)); controls.attach(&routing_switch, 1, 0, 1, 1); - let view_label = gtk::Label::new(Some("View mode")); + let view_label = gtk::Label::new(Some("Preview mode")); view_label.set_halign(gtk::Align::Start); controls.attach(&view_label, 0, 1, 1, 1); @@ -160,7 +173,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); root.append(&button_row); - let start_button = gtk::Button::with_label("Start Session"); + let start_button = gtk::Button::with_label("Start Relay"); let stop_button = gtk::Button::with_label("End Relay"); let view_toggle_button = gtk::Button::with_label(""); let input_toggle_button = gtk::Button::with_label(""); @@ -178,12 +191,24 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { root.append(&probe_hint); let note = gtk::Label::new(Some( - "Unified mode renders both streams side-by-side in one window. Use Pop Out Windows to split back into full windows. Input swap key defaults to Pause and can be changed.", + "The live preview stays in this launcher by default so you can watch both displays before handing control over. Start Relay keeps the preview here in unified mode, Pop Out Windows switches back to external video windows, and Use Remote Inputs hands keyboard and mouse to the remote side.", )); note.set_wrap(true); note.set_halign(gtk::Align::Start); root.append(¬e); + match LauncherPreview::new(server_addr.as_ref().to_string()) { + Ok(preview) => { + preview.install_on_picture(0, &left_picture, &left_status); + preview.install_on_picture(1, &right_picture, &right_status); + } + Err(err) => { + let msg = format!("Preview unavailable: {err}"); + left_status.set_text(&msg); + right_status.set_text(&msg); + } + } + { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); @@ -369,7 +394,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } - window.set_child(Some(&root)); + scroll.set_child(Some(&root)); + window.set_child(Some(&scroll)); window.present(); }); } @@ -554,6 +580,30 @@ fn sync_toggle_button_labels( }); } +#[cfg(not(coverage))] +fn build_preview_pane(title: &str) -> (gtk::Box, gtk::Picture, gtk::Label) { + let pane = gtk::Box::new(gtk::Orientation::Vertical, 6); + pane.set_hexpand(true); + pane.set_vexpand(true); + + let label = gtk::Label::new(Some(title)); + label.set_halign(gtk::Align::Start); + pane.append(&label); + + let picture = gtk::Picture::new(); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_can_shrink(true); + picture.set_size_request(440, 248); + pane.append(&picture); + + let status = gtk::Label::new(Some("Waiting for stream...")); + status.set_halign(gtk::Align::Start); + pane.append(&status); + + (pane, picture, status) +} + #[cfg(all(test, coverage))] mod tests { use super::run_gui_launcher; diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index fafe53d..9ee7a2b 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -3,7 +3,7 @@ "client/src/app.rs": { "clippy_warnings": 42, "doc_debt": 10, - "loc": 537 + "loc": 548 }, "client/src/app_support.rs": { "clippy_warnings": 0, @@ -65,15 +65,20 @@ "doc_debt": 4, "loc": 176 }, + "client/src/launcher/preview.rs": { + "clippy_warnings": 20, + "doc_debt": 7, + "loc": 258 + }, "client/src/launcher/state.rs": { "clippy_warnings": 8, "doc_debt": 9, "loc": 234 }, "client/src/launcher/ui.rs": { - "clippy_warnings": 6, - "doc_debt": 6, - "loc": 565 + "clippy_warnings": 8, + "doc_debt": 7, + "loc": 615 }, "client/src/layout.rs": { "clippy_warnings": 6, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index a010628..de79d76 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -2,7 +2,7 @@ "files": { "client/src/app.rs": { "line_percent": 95.1219512195122, - "loc": 537 + "loc": 548 }, "client/src/app_support.rs": { "line_percent": 100.0, @@ -49,12 +49,12 @@ "loc": 176 }, "client/src/launcher/state.rs": { - "line_percent": 99.32432432432432, + "line_percent": 97.97297297297297, "loc": 234 }, "client/src/launcher/ui.rs": { "line_percent": 100.0, - "loc": 565 + "loc": 615 }, "client/src/layout.rs": { "line_percent": 97.72727272727273,