diff --git a/Cargo.lock b/Cargo.lock index 4089721..3a5d97b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.15.2" +version = "0.15.3" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.15.2" +version = "0.15.3" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.15.2" +version = "0.15.3" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index d36e7a8..7e0521f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.15.2" +version = "0.15.3" edition = "2024" [dependencies] diff --git a/client/src/launcher/preview/status_pipeline.rs b/client/src/launcher/preview/status_pipeline.rs index 4dddb74..9ce92fb 100644 --- a/client/src/launcher/preview/status_pipeline.rs +++ b/client/src/launcher/preview/status_pipeline.rs @@ -179,18 +179,16 @@ fn build_preview_pipeline( profile: PreviewProfile, decoder_name: &str, ) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> { - let source_mode = eye_source_mode_for_request( + let _display_bounds = (profile.display_width, profile.display_height); + let _source_mode = eye_source_mode_for_request( profile.requested_width.max(2) as u32, profile.requested_height.max(2) as u32, ); - let (render_width, render_height) = - preview_render_size(profile, source_mode.width, source_mode.height); 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 name=preview_parse disable-passthrough=true ! {} name=decoder ! videoconvert ! \ - videoscale add-borders=false ! \ - video/x-raw,format=RGBA,width=(int){render_width},height=(int){render_height},pixel-aspect-ratio=1/1 ! \ + video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \ appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true", decoder_name, ); @@ -219,8 +217,6 @@ fn build_preview_pipeline( appsink.set_caps(Some( &gst::Caps::builder("video/x-raw") .field("format", "RGBA") - .field("width", render_width) - .field("height", render_height) .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) .build(), )); @@ -228,27 +224,6 @@ fn build_preview_pipeline( Ok((pipeline, appsrc, appsink, decoder_name.to_string())) } -#[cfg(not(coverage))] -fn preview_render_size( - profile: PreviewProfile, - source_width: u32, - source_height: u32, -) -> (i32, i32) { - fn round_down_even(value: i32) -> i32 { - let clamped = value.max(2); - clamped - (clamped % 2) - } - - let source_w = source_width.max(2) as f32; - let source_h = source_height.max(2) as f32; - let max_w = profile.display_width.max(2) as f32; - let max_h = profile.display_height.max(2) as f32; - let scale = (max_w / source_w).min(max_h / source_h).clamp(0.01, 1.0); - let render_w = round_down_even((source_w * scale).round() as i32); - let render_h = round_down_even((source_h * scale).round() as i32); - (render_w.max(2), render_h.max(2)) -} - #[cfg(not(coverage))] fn preview_decoder_candidates() -> Vec { let mut candidates = Vec::new(); diff --git a/client/src/launcher/tests/preview.rs b/client/src/launcher/tests/preview.rs index 7180200..9073230 100644 --- a/client/src/launcher/tests/preview.rs +++ b/client/src/launcher/tests/preview.rs @@ -2,7 +2,7 @@ use super::{ DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT, INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH, LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry, - preview_render_size, sanitize_preview_request, + sanitize_preview_request, }; use crate::launcher::state::{CaptureSizePreset, LauncherState}; use futures::stream; @@ -155,18 +155,6 @@ fn breakout_preview_profile_defaults_to_higher_quality() { assert_eq!(profile.max_bitrate_kbit, 18_000); } -#[test] -fn preview_render_size_fits_source_into_display_budget() { - let profile = PreviewSurface::Inline.profile(); - assert_eq!(preview_render_size(profile, 1920, 1080), (960, 540)); -} - -#[test] -fn preview_render_size_never_upscales_beyond_source_geometry() { - let profile = PreviewSurface::Window.profile(); - assert_eq!(preview_render_size(profile, 1280, 720), (1280, 720)); -} - #[test] fn preview_request_sanitizer_keeps_requested_source_geometry() { let adapted = sanitize_preview_request(1920, 1080, 60, 18_000); diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 8675f5f..f47ddf6 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -377,7 +377,7 @@ fn breakout_size_changes_resize_the_open_popout_window() { fn server_chip_state_tracks_connection_not_just_reachability() { let mut state = LauncherState::new(); assert_eq!(server_light_state(&state, false), StatusLightState::Idle); - assert_eq!(server_version_label(&state), "-"); + assert_eq!(server_version_label(&state), "???"); state.set_server_available(true); state.set_server_version(Some(crate::VERSION.to_string())); @@ -396,7 +396,7 @@ fn server_chip_state_tracks_connection_not_just_reachability() { state.set_server_version(Some(" ".to_string())); assert_eq!(server_light_state(&state, false), StatusLightState::Idle); - assert_eq!(server_version_label(&state), "-"); + assert_eq!(server_version_label(&state), "???"); } #[test] diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 656eed2..041a91b 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -8,6 +8,7 @@ use { super::diagnostics::PerformanceSample, super::launcher_clipboard_control_path, super::launcher_focus_signal_path, + super::preview::{LauncherPreview, PreviewSurface}, super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode}, super::state::{ BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, diff --git a/client/src/launcher/ui/stage_device_bindings.rs b/client/src/launcher/ui/stage_device_bindings.rs index 21329b8..463639b 100644 --- a/client/src/launcher/ui/stage_device_bindings.rs +++ b/client/src/launcher/ui/stage_device_bindings.rs @@ -157,6 +157,8 @@ let mut state = state.borrow_mut(); state.set_server_available(false); state.set_server_version(None); + state.set_server_media_caps(None, None, None, None); + state.set_capture_power(CapturePowerStatus::default()); } if let Some(preview) = preview.as_ref() { preview.set_server_addr(server_addr.clone()); diff --git a/client/src/launcher/ui/utility_button_bindings.rs b/client/src/launcher/ui/utility_button_bindings.rs index 1d31679..8172008 100644 --- a/client/src/launcher/ui/utility_button_bindings.rs +++ b/client/src/launcher/ui/utility_button_bindings.rs @@ -1,6 +1,5 @@ { - const EYE_RECORD_FPS: u32 = 20; - const EYE_RECORD_FRAME_INTERVAL_MS: u64 = 1000 / EYE_RECORD_FPS as u64; + const DEFAULT_EYE_RECORD_FPS: u32 = 30; #[derive(Default)] struct EyeRecordState { @@ -10,6 +9,8 @@ output_path: Option, next_frame_index: u32, captured_frames: u32, + encode_fps: u32, + encode_bitrate_kbit: u32, } fn eye_slug(title: &str) -> &'static str { @@ -97,6 +98,35 @@ .map_err(|err| format!("could not write {}: {err}", output_path.display())) } + fn recording_interval_ms(record_fps: u32) -> u64 { + let fps = record_fps.max(1); + (1000_u64 / fps as u64).max(1) + } + + fn best_effort_recording_profile( + state: &LauncherState, + preview: Option<&LauncherPreview>, + monitor_id: usize, + ) -> (u32, u32) { + let choice = state + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| state.capture_size_choice(monitor_id)); + let mut fps = if choice.fps == 0 { + DEFAULT_EYE_RECORD_FPS + } else { + choice.fps.max(1) + }; + if let Some(snapshot) = + preview.and_then(|feed| feed.snapshot_metrics(monitor_id, PreviewSurface::Inline)) + && snapshot.server_fps.is_finite() + && snapshot.server_fps >= 1.0 + { + fps = snapshot.server_fps.round().clamp(1.0, 120.0) as u32; + } + let bitrate_kbit = choice.max_bitrate_kbit.max(800); + (fps, bitrate_kbit) + } + fn write_record_frame(state: &mut EyeRecordState, picture: >k::Picture) -> Result<(), String> { let frame_dir = state .frame_dir @@ -121,8 +151,12 @@ .take() .ok_or_else(|| "recording output path was not initialized".to_string())?; let captured_frames = state.captured_frames; + let encode_fps = state.encode_fps.max(1); + let encode_bitrate_kbit = state.encode_bitrate_kbit.max(800); state.captured_frames = 0; state.next_frame_index = 0; + state.encode_fps = 0; + state.encode_bitrate_kbit = 0; if captured_frames < 2 { let _ = std::fs::remove_dir_all(&frame_dir); @@ -130,6 +164,7 @@ } let frame_pattern = frame_dir.join("frame-%06d.png"); + let bitrate_arg = format!("{encode_bitrate_kbit}k"); let encode = Command::new("ffmpeg") .args([ "-hide_banner", @@ -137,13 +172,17 @@ "error", "-y", "-framerate", - &EYE_RECORD_FPS.to_string(), + &encode_fps.to_string(), "-i", &frame_pattern.to_string_lossy(), "-c:v", "libx264", "-pix_fmt", "yuv420p", + "-r", + &encode_fps.to_string(), + "-b:v", + &bitrate_arg, &output_path.to_string_lossy(), ]) .status() @@ -300,6 +339,8 @@ let pane = pane.clone(); let widgets = widgets_for_ui.clone(); let save_state = Rc::clone(&save_state); + let state = Rc::clone(&state); + let preview = preview.clone(); let record_button = pane.record_button.clone(); record_button.connect_clicked(move |button| { if save_state.borrow().timer.is_some() { @@ -330,6 +371,10 @@ return; } + let (record_fps, record_bitrate_kbit) = { + let state = state.borrow(); + best_effort_recording_profile(&state, preview.as_deref(), monitor_id) + }; let root = { let borrowed = save_state.borrow(); match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) { @@ -361,13 +406,15 @@ state.output_path = Some(output_path.clone()); state.next_frame_index = 0; state.captured_frames = 0; + state.encode_fps = record_fps; + state.encode_bitrate_kbit = record_bitrate_kbit; } let pane_for_tick = pane.clone(); let widgets_for_tick = widgets.clone(); let save_state_for_tick = Rc::clone(&save_state); let timer = glib::timeout_add_local( - Duration::from_millis(EYE_RECORD_FRAME_INTERVAL_MS), + Duration::from_millis(recording_interval_ms(record_fps)), move || { let mut state = save_state_for_tick.borrow_mut(); if state.frame_dir.is_none() { @@ -385,8 +432,8 @@ save_state.borrow_mut().timer = Some(timer); button.set_label("Stop"); widgets.status_label.set_text(&format!( - "Recording {}... press Stop to finish.", - pane.title + "Recording {} at {} fps (~{} kbit)... press Stop to finish.", + pane.title, record_fps, record_bitrate_kbit )); }); } @@ -400,7 +447,7 @@ widgets.usb_recover_button.connect_clicked(move |_| { let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click.status_label.set_text( - "Requesting a forced USB gadget re-enumeration on the relay host...", + "Recover USB 1/3: sending gadget reset request to relay host...", ); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { @@ -411,20 +458,20 @@ glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { Ok(Ok(())) => { widgets.status_label.set_text( - "USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.", + "Recover USB 2/3: relay acknowledged reset. Recover USB 3/3: waiting for USB/UAC/UVC chips to settle.", ); glib::ControlFlow::Break } Ok(Err(err)) => { widgets .status_label - .set_text(&format!("USB gadget recovery failed: {err}")); + .set_text(&format!("Recover USB failed: {err}")); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { widgets.status_label.set_text( - "USB gadget recovery ended unexpectedly before the relay answered.", + "Recover USB failed: relay stopped responding before completion.", ); glib::ControlFlow::Break } @@ -441,7 +488,7 @@ let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click .status_label - .set_text("Requesting UAC recovery (USB gadget rebuild) on the relay host..."); + .set_text("Recover UAC 1/3: sending gadget reset request to relay host..."); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); @@ -451,20 +498,20 @@ glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { Ok(Ok(())) => { widgets.status_label.set_text( - "UAC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate audio.", + "Recover UAC 2/3: relay acknowledged reset. Recover UAC 3/3: waiting for UAC chip to settle.", ); glib::ControlFlow::Break } Ok(Err(err)) => { widgets .status_label - .set_text(&format!("UAC recovery failed: {err}")); + .set_text(&format!("Recover UAC failed: {err}")); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { widgets.status_label.set_text( - "UAC recovery ended unexpectedly before the relay answered.", + "Recover UAC failed: relay stopped responding before completion.", ); glib::ControlFlow::Break } @@ -481,7 +528,7 @@ let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click .status_label - .set_text("Requesting UVC recovery (USB gadget rebuild) on the relay host..."); + .set_text("Recover UVC 1/3: sending gadget reset request to relay host..."); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); @@ -491,20 +538,20 @@ glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { Ok(Ok(())) => { widgets.status_label.set_text( - "UVC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate webcam video.", + "Recover UVC 2/3: relay acknowledged reset. Recover UVC 3/3: waiting for UVC chip to settle.", ); glib::ControlFlow::Break } Ok(Err(err)) => { widgets .status_label - .set_text(&format!("UVC recovery failed: {err}")); + .set_text(&format!("Recover UVC failed: {err}")); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { widgets.status_label.set_text( - "UVC recovery ended unexpectedly before the relay answered.", + "Recover UVC failed: relay stopped responding before completion.", ); glib::ControlFlow::Break } diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 4b5cb90..18ea02d 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -72,6 +72,7 @@ pub fn build_launcher_view( camera_preview_stack, camera_preview_frame, camera_preview, + webcam_transport_combo, camera_mirror_button, camera_mirror_revealer, camera_status, diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs index 2b950c8..c1b900c 100644 --- a/client/src/launcher/ui_components/assemble_view.rs +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -149,6 +149,7 @@ swap_key_button: swap_key_button.clone(), camera_test_button: camera_test_button.clone(), camera_preview_stack: camera_preview_stack.clone(), + webcam_transport_combo: webcam_transport_combo.clone(), camera_mirror_button: camera_mirror_button.clone(), camera_mirror_revealer: camera_mirror_revealer.clone(), microphone_test_button: microphone_test_button.clone(), @@ -184,6 +185,7 @@ camera_preview_stack, camera_preview_frame, camera_preview, + webcam_transport_combo, camera_mirror_button, camera_status, }, diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs index 993cdda..63fd9f8 100644 --- a/client/src/launcher/ui_components/build_contexts.rs +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -42,6 +42,7 @@ struct DeviceControlsContext { camera_preview_stack: gtk::Stack, camera_preview_frame: gtk::AspectFrame, camera_preview: gtk::Picture, + webcam_transport_combo: gtk::ComboBoxText, camera_mirror_button: gtk::ToggleButton, camera_mirror_revealer: gtk::Revealer, camera_status: gtk::Label, diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index c564be3..03c9a05 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -328,7 +328,19 @@ camera_preview_stack.add_named(&camera_preview_overlay, Some("live")); camera_preview_stack.set_visible_child_name("idle"); camera_preview_shell.append(&camera_preview_stack); - let webcam_group = build_subgroup("Webcam Preview"); + let webcam_transport_combo = gtk::ComboBoxText::new(); + webcam_transport_combo.add_css_class("compact-combo"); + webcam_transport_combo.append(Some("mjpeg"), "MJPEG"); + webcam_transport_combo.append(Some("yuy2"), "YUY2"); + webcam_transport_combo.append(Some("h264"), "H.264"); + webcam_transport_combo.set_active_id(Some("mjpeg")); + webcam_transport_combo.set_sensitive(false); + webcam_transport_combo.set_size_request(112, -1); + webcam_transport_combo.set_tooltip_text(Some( + "Upstream transport format. MJPEG is pinned while sync hardening is in progress.", + )); + let webcam_group = + build_subgroup_with_action("Webcam Preview", Some(webcam_transport_combo.upcast_ref())); webcam_group.set_hexpand(true); webcam_group.set_vexpand(true); webcam_group.set_valign(gtk::Align::Fill); @@ -383,6 +395,7 @@ camera_preview_stack, camera_preview_frame, camera_preview, + webcam_transport_combo, camera_mirror_button, camera_mirror_revealer, camera_status, diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs index 3864a9b..0a0e9dd 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -19,7 +19,7 @@ connection_body.append(&relay_grid); - let recovery_heading = gtk::Label::new(Some("Recovery")); + let recovery_heading = gtk::Label::new(Some("Recover")); recovery_heading.add_css_class("subgroup-title"); recovery_heading.set_halign(gtk::Align::Start); let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); @@ -29,14 +29,15 @@ let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); recovery_buttons.set_hexpand(true); recovery_buttons.set_homogeneous(true); - let usb_recover_button = rail_button("Recover USB", "Re-enumerate remote USB gadget."); - let uac_recover_button = rail_button("Recover UAC", "Rebuild remote USB audio function."); - let uvc_recover_button = rail_button("Recover UVC", "Rebuild remote USB webcam function."); + let usb_recover_button = rail_button("USB", "Re-enumerate remote USB gadget."); + let uac_recover_button = rail_button("UAC", "Rebuild remote USB audio function."); + let uvc_recover_button = rail_button("UVC", "Rebuild remote USB webcam function."); recovery_buttons.append(&usb_recover_button); recovery_buttons.append(&uac_recover_button); recovery_buttons.append(&uvc_recover_button); recovery_row.append(&recovery_buttons); connection_body.append(&recovery_row); + connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); let tools_heading = gtk::Label::new(Some("Tools")); tools_heading.add_css_class("subgroup-title"); diff --git a/client/src/launcher/ui_components/display_pane.rs b/client/src/launcher/ui_components/display_pane.rs index 8213a8d..c844e95 100644 --- a/client/src/launcher/ui_components/display_pane.rs +++ b/client/src/launcher/ui_components/display_pane.rs @@ -17,21 +17,25 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { stream_status.set_max_width_chars(18); stream_status.set_tooltip_text(Some("Eye stream status.")); - let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); - header.set_hexpand(true); + let header_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + header_row.set_hexpand(true); let title_label = gtk::Label::new(Some(title)); title_label.add_css_class("title-4"); title_label.set_halign(gtk::Align::Start); - title_label.set_hexpand(false); + title_label.set_hexpand(true); let capture_label = gtk::Label::new(Some(capture_path)); capture_label.add_css_class("dim-label"); capture_label.set_halign(gtk::Align::End); capture_label.set_hexpand(false); capture_label.set_ellipsize(pango::EllipsizeMode::Start); - header.append(&title_label); - header.append(&stream_status); - header.append(&capture_label); - root.append(&header); + header_row.append(&title_label); + header_row.append(&capture_label); + let header_overlay = gtk::Overlay::new(); + header_overlay.set_hexpand(true); + header_overlay.set_child(Some(&header_row)); + header_overlay.add_overlay(&stream_status); + header_overlay.set_clip_overlay(&stream_status, true); + root.append(&header_overlay); let picture = gtk::Picture::new(); picture.add_css_class("eye-preview-surface"); diff --git a/client/src/launcher/ui_components/panel_chips.rs b/client/src/launcher/ui_components/panel_chips.rs index 4110037..ca95015 100644 --- a/client/src/launcher/ui_components/panel_chips.rs +++ b/client/src/launcher/ui_components/panel_chips.rs @@ -25,12 +25,23 @@ fn build_panel_with_action(title: &str, action: Option<>k::Widget>) -> (gtk::B } fn build_subgroup(title: &str) -> gtk::Box { + build_subgroup_with_action(title, None) +} + +fn build_subgroup_with_action(title: &str, action: Option<>k::Widget>) -> gtk::Box { let group = gtk::Box::new(gtk::Orientation::Vertical, 8); group.add_css_class("subgroup"); + let heading_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + heading_row.set_hexpand(true); let heading = gtk::Label::new(Some(title)); heading.add_css_class("subgroup-title"); heading.set_halign(gtk::Align::Start); - group.append(&heading); + heading.set_hexpand(true); + heading_row.append(&heading); + if let Some(action) = action { + heading_row.append(action); + } + group.append(&heading_row); group } @@ -38,6 +49,7 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); chip.add_css_class("status-chip"); chip.set_hexpand(false); + chip.set_size_request(96, -1); let label_widget = gtk::Label::new(Some(label)); label_widget.add_css_class("status-chip-label"); @@ -47,6 +59,9 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { value_widget.add_css_class("status-chip-value"); value_widget.set_halign(gtk::Align::Center); value_widget.set_xalign(0.5); + value_widget.set_ellipsize(pango::EllipsizeMode::End); + value_widget.set_single_line_mode(true); + value_widget.set_width_chars(9); chip.append(&label_widget); chip.append(&value_widget); (chip, value_widget) @@ -56,6 +71,7 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); chip.add_css_class("status-chip"); chip.set_hexpand(false); + chip.set_size_request(96, -1); let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6); meta.add_css_class("status-chip-meta"); @@ -73,6 +89,9 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box value_widget.add_css_class("status-chip-value"); value_widget.set_halign(gtk::Align::Center); value_widget.set_xalign(0.5); + value_widget.set_ellipsize(pango::EllipsizeMode::End); + value_widget.set_single_line_mode(true); + value_widget.set_width_chars(9); chip.append(&meta); chip.append(&value_widget); (chip, light, value_widget) diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index aa23c1d..d5806fe 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -156,6 +156,7 @@ pub struct LauncherWidgets { pub swap_key_button: gtk::Button, pub camera_test_button: gtk::Button, pub camera_preview_stack: gtk::Stack, + pub webcam_transport_combo: gtk::ComboBoxText, pub camera_mirror_button: gtk::ToggleButton, pub camera_mirror_revealer: gtk::Revealer, pub microphone_test_button: gtk::Button, @@ -178,6 +179,7 @@ pub struct DeviceStageWidgets { pub camera_preview_stack: gtk::Stack, pub camera_preview_frame: gtk::AspectFrame, pub camera_preview: gtk::Picture, + pub webcam_transport_combo: gtk::ComboBoxText, pub camera_mirror_button: gtk::ToggleButton, pub camera_status: gtk::Label, } diff --git a/client/src/launcher/ui_runtime/status_details.rs b/client/src/launcher/ui_runtime/status_details.rs index 0e3eb09..024c1b0 100644 --- a/client/src/launcher/ui_runtime/status_details.rs +++ b/client/src/launcher/ui_runtime/status_details.rs @@ -103,7 +103,7 @@ fn normalize_version(version: &str) -> &str { fn server_version_label(state: &LauncherState) -> String { if !state.server_available { - return "-".to_string(); + return "???".to_string(); } let version = state .server_version @@ -113,7 +113,7 @@ fn server_version_label(state: &LauncherState) -> String { match version { Some(version) if version.starts_with('v') => version.to_string(), Some(version) => format!("v{version}"), - None => "-".to_string(), + None => "???".to_string(), } } diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index 6247670..76beb06 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -44,14 +44,16 @@ pub type RelayChild = Child; pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { let relay_live = child_running || state.remote_active; + let server_label = server_version_label(state); set_status_light( &widgets.summary.relay_light, server_light_state(state, relay_live), ); + widgets.summary.relay_value.set_text(&server_label); widgets .summary .relay_value - .set_text(&server_version_label(state)); + .set_tooltip_text(Some(&server_label)); set_status_light( &widgets.summary.routing_light, StatusLightState::from_active(matches!(state.routing, InputRouting::Remote)), @@ -60,14 +62,21 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi .summary .routing_value .set_text(&capitalize(routing_name(state.routing))); + let gpio_label = if state.server_available { + gpio_power_label(&state.capture_power) + } else { + "Offline".to_string() + }; set_status_light( &widgets.summary.gpio_light, - gpio_light_state(&state.capture_power), + if state.server_available { + gpio_light_state(&state.capture_power) + } else { + StatusLightState::Idle + }, ); - widgets - .summary - .gpio_value - .set_text(&gpio_power_label(&state.capture_power)); + widgets.summary.gpio_value.set_text(&gpio_label); + widgets.summary.gpio_value.set_tooltip_text(Some(&gpio_label)); widgets .summary .shortcut_value @@ -76,16 +85,22 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi let (usb_state, usb_value) = recovery_usb_health(state); set_status_light(&widgets.summary.usb_light, usb_state); widgets.summary.usb_value.set_text(&usb_value); + widgets.summary.usb_value.set_tooltip_text(Some(&usb_value)); let (uac_state, uac_value) = recovery_uac_health(state); set_status_light(&widgets.summary.uac_light, uac_state); widgets.summary.uac_value.set_text(&uac_value); + widgets.summary.uac_value.set_tooltip_text(Some(&uac_value)); let (uvc_state, uvc_value) = recovery_uvc_health(state); set_status_light(&widgets.summary.uvc_light, uvc_state); widgets.summary.uvc_value.set_text(&uvc_value); + widgets.summary.uvc_value.set_tooltip_text(Some(&uvc_value)); - widgets - .power_detail - .set_text(&capture_power_detail(&state.capture_power)); + let power_detail = if state.server_available { + capture_power_detail(&state.capture_power) + } else { + "relay host is offline; GPIO power state is unavailable".to_string() + }; + widgets.power_detail.set_text(&power_detail); if (widgets.audio_gain_scale.value() - state.audio_gain_percent as f64).abs() > f64::EPSILON { widgets .audio_gain_scale @@ -177,6 +192,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .speaker_test_button .set_sensitive(!relay_live && state.channels.audio); + widgets.webcam_transport_combo.set_sensitive(false); widgets.input_toggle_button.set_label(match state.routing { InputRouting::Remote => "Route Local", InputRouting::Local => "Route Remote", diff --git a/common/Cargo.toml b/common/Cargo.toml index 54eda69..36c5bc6 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.15.2" +version = "0.15.3" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index dc30ae2..afec6b9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.15.2" +version = "0.15.3" edition = "2024" autobins = false diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index 0a1f140..8085202 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -88,13 +88,13 @@ fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width() assert!(UI_LAYOUT_SRC.contains("capture_label.set_ellipsize(pango::EllipsizeMode::Start);")); assert!(!UI_LAYOUT_SRC.contains("root.append(&stream_status);")); assert!(UI_LAYOUT_SRC.contains("stream_status.add_css_class(\"eye-inline-status\");")); + assert!(UI_LAYOUT_SRC.contains("let header_overlay = gtk::Overlay::new();")); + assert!(UI_LAYOUT_SRC.contains("header_row.append(&title_label);")); + assert!(UI_LAYOUT_SRC.contains("header_row.append(&capture_label);")); + assert!(UI_LAYOUT_SRC.contains("header_overlay.add_overlay(&stream_status);")); assert!( - source_index("header.append(&title_label);") - < source_index("header.append(&stream_status);") - ); - assert!( - source_index("header.append(&stream_status);") - < source_index("header.append(&capture_label);") + source_index("header_row.append(&title_label);") + < source_index("header_row.append(&capture_label);") ); assert!( source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);") @@ -281,7 +281,7 @@ fn relay_controls_keep_connect_inline_with_server_entry() { assert!(UI_LAYOUT_SRC.contains("let start_button = rail_button(\"Connect\"")); assert!(UI_LAYOUT_SRC.contains("pub(crate) fn set_rail_button_label(")); assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);")); - assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recovery\"));")); + assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recover\"));")); assert!(UI_LAYOUT_SRC.contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")); assert!(UI_LAYOUT_SRC.contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);")); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.set_homogeneous(true);")); @@ -292,9 +292,9 @@ fn relay_controls_keep_connect_inline_with_server_entry() { assert!(UI_LAYOUT_SRC.contains("tools_buttons.set_homogeneous(true);")); assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(10);")); assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\"")); - assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(\"Recover USB\"")); - assert!(UI_LAYOUT_SRC.contains("let uac_recover_button = rail_button(\"Recover UAC\"")); - assert!(UI_LAYOUT_SRC.contains("let uvc_recover_button = rail_button(\"Recover UVC\"")); + assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(\"USB\"")); + assert!(UI_LAYOUT_SRC.contains("let uac_recover_button = rail_button(\"UAC\"")); + assert!(UI_LAYOUT_SRC.contains("let uvc_recover_button = rail_button(\"UVC\"")); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&usb_recover_button);")); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uac_recover_button);")); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uvc_recover_button);")); @@ -373,8 +373,8 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() { UI_LAYOUT_SRC .matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));") .count(), - 2, - "the operations rail should not gain extra vertical sections that stretch the lower layout" + 3, + "recover/tools/gpio/inputs sections should remain visually separated" ); assert!( source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));") diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index 12f1b57..637813f 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -174,10 +174,10 @@ fn launcher_utility_buttons_still_bind_to_live_actions() { assert!(UI_SRC.contains("clip saved to")); assert!(UI_SRC.contains("record_button.connect_clicked")); assert!(UI_SRC.contains("recording saved to")); - assert!(UI_SRC.contains("Recording {}... press Stop to finish.")); + assert!(UI_SRC.contains("press Stop to finish.")); assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked")); assert!(UI_SRC.contains("reset_usb_gadget(&server_addr)")); - assert!(UI_SRC.contains("USB gadget recovery requested.")); + assert!(UI_SRC.contains("Recover USB 2/3: relay acknowledged reset.")); } #[test] @@ -188,7 +188,7 @@ fn server_chip_distinguishes_reachable_from_connected() { assert!(UI_RUNTIME_SRC.contains("} else if relay_live {")); assert!(UI_RUNTIME_SRC.contains("StatusLightState::Caution")); assert!(UI_RUNTIME_SRC.contains("fn server_version_label(")); - assert!(UI_RUNTIME_SRC.contains("return \"-\".to_string();")); + assert!(UI_RUNTIME_SRC.contains("return \"???\".to_string();")); } #[test]