launcher: embed live preview controls

This commit is contained in:
Brad Stein 2026-04-14 14:38:03 -03:00
parent 8dd3461be0
commit 3897239ef4
9 changed files with 443 additions and 95 deletions

View File

@ -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::<usize>().ok()),
);
let dump_video = std::env::var("LESAVKA_DUMP_VIDEO").is_ok();
let (video_tx, mut video_rx) = tokio::sync::mpsc::channel::<VideoPacket>(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::<usize>().ok()),
);
let dump_video = std::env::var("LESAVKA_DUMP_VIDEO").is_ok();
let (video_tx, mut video_rx) =
tokio::sync::mpsc::channel::<VideoPacket>(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::<u32>().ok())
.unwrap_or(4_000);
.unwrap_or(6_000);
for monitor_id in 0..=1 {
let ep = ep.clone();
let tx = tx.clone();

View File

@ -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<CameraCodec> {
#[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]

View File

@ -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]

View File

@ -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<String, String> {
"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()];

View File

@ -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<Self> {
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: &gtk::Picture,
status_label: &gtk::Label,
) {
if let Some(feed) = self.feeds.get(monitor_id) {
feed.install_on_picture(picture, status_label);
}
}
}
#[cfg(not(coverage))]
struct PreviewFeed {
latest: Arc<Mutex<Option<PreviewFrame>>>,
}
#[cfg(not(coverage))]
impl PreviewFeed {
fn spawn(server_addr: String, monitor_id: u32) -> Result<Self> {
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: &gtk::Picture, status_label: &gtk::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<u8>,
}
#[cfg(not(coverage))]
fn run_preview_feed(
server_addr: String,
monitor_id: u32,
latest: Arc<Mutex<Option<PreviewFrame>>>,
) -> 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::<gst::Pipeline>()
.expect("preview pipeline");
let appsrc = pipeline
.by_name("src")
.context("missing preview appsrc")?
.downcast::<gst_app::AppSrc>()
.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::<gst_app::AppSink>()
.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<PreviewFrame> {
let caps = sample.caps()?;
let structure = caps.structure(0)?;
let width = structure.get::<i32>("width").ok()?;
let height = structure.get::<i32>("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::<u32>().ok())
.unwrap_or(2_500)
}

View File

@ -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());

View File

@ -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(&note);
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;

View File

@ -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,

View File

@ -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,