diff --git a/client/src/launcher/device_test.rs b/client/src/launcher/device_test.rs index d197036..1218e90 100644 --- a/client/src/launcher/device_test.rs +++ b/client/src/launcher/device_test.rs @@ -11,8 +11,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -const CAMERA_PREVIEW_WIDTH: i32 = 360; -const CAMERA_PREVIEW_HEIGHT: i32 = 202; +const CAMERA_PREVIEW_WIDTH: i32 = 300; +const CAMERA_PREVIEW_HEIGHT: i32 = 168; const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview."; const MIC_MONITOR_RATE: i32 = 16_000; const MIC_MONITOR_CHANNELS: i32 = 1; diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs index 0471768..fe83b2e 100644 --- a/client/src/launcher/preview.rs +++ b/client/src/launcher/preview.rs @@ -13,7 +13,7 @@ use gtk::prelude::WidgetExt; #[cfg(not(coverage))] use gtk::{gdk, glib}; use lesavka_common::{ - eye_source::{display_size_for_source_mode, eye_source_mode_for_request}, + eye_source::eye_source_mode_for_request, lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient}, }; #[cfg(not(coverage))] @@ -1416,7 +1416,8 @@ fn build_preview_pipeline( profile.requested_width.max(2) as u32, profile.requested_height.max(2) as u32, ); - let (render_width, render_height) = display_size_for_source_mode(source_mode); + 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 ! \ @@ -1459,6 +1460,27 @@ fn build_preview_pipeline( Ok((pipeline, appsrc, appsink, decoder_name)) } +#[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).min(1.0).max(0.01); + 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 push_preview_packet(appsrc: &gst_app::AppSrc, pkt: VideoPacket) { let mut buf = gst::Buffer::from_slice(pkt.data); @@ -1641,7 +1663,7 @@ mod tests { 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, - sanitize_preview_request, + preview_render_size, sanitize_preview_request, }; use crate::launcher::state::{CaptureSizePreset, LauncherState}; use futures::stream; @@ -1798,6 +1820,18 @@ mod tests { 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/ui.rs b/client/src/launcher/ui.rs index 59b84e4..072ac42 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -774,6 +774,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { Rc::new(Cell::new(Instant::now() + Duration::from_millis(250))); let next_diagnostics_sample = Rc::new(Cell::new(Instant::now() + Duration::from_secs(1))); + let preview_session_active = Rc::new(Cell::new(false)); let (clipboard_tx, clipboard_rx) = std::sync::mpsc::channel::(); let (log_tx, log_rx) = std::sync::mpsc::channel::(); @@ -1822,9 +1823,20 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let diagnostics_process = Rc::clone(&diagnostics_process); let next_diagnostics_probe = Rc::clone(&next_diagnostics_probe); let next_diagnostics_sample = Rc::clone(&next_diagnostics_sample); + let preview_session_active = Rc::clone(&preview_session_active); let log_tx = log_tx.clone(); glib::timeout_add_local(Duration::from_millis(180), move || { let child_running = reap_exited_child(&child_proc); + if let Some(preview) = preview.as_ref() { + let desired_preview_active = { + let state_snapshot = state.borrow(); + session_preview_active(&state_snapshot, child_running) + }; + if preview_session_active.get() != desired_preview_active { + preview.set_session_active(desired_preview_active); + preview_session_active.set(desired_preview_active); + } + } if !child_running && state.borrow().remote_active { let power_mode = { let mut state = state.borrow_mut(); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index fbc3bdd..2847d1d 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -111,8 +111,8 @@ const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ass const LAUNCHER_DEFAULT_WIDTH: i32 = 1380; const LAUNCHER_DEFAULT_HEIGHT: i32 = 860; const OPERATIONS_RAIL_WIDTH: i32 = 288; -const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 202; -const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 360; +const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 168; +const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 300; pub fn build_launcher_view( app: >k::Application, @@ -326,7 +326,7 @@ pub fn build_launcher_view( let camera_preview = gtk::Picture::new(); camera_preview.set_can_shrink(false); camera_preview.set_hexpand(true); - camera_preview.set_vexpand(true); + camera_preview.set_vexpand(false); camera_preview.set_size_request( CAMERA_PREVIEW_VIEWPORT_WIDTH, CAMERA_PREVIEW_VIEWPORT_HEIGHT, @@ -337,7 +337,7 @@ pub fn build_launcher_view( camera_status.add_css_class("dim-label"); camera_status.set_wrap(true); camera_status.set_xalign(0.0); - camera_status.set_visible(false); + camera_status.set_visible(true); let camera_preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); camera_preview_shell.set_hexpand(true); camera_preview_shell.set_vexpand(false); @@ -356,17 +356,15 @@ pub fn build_launcher_view( let playback_group = build_subgroup("Mic Playback"); let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 8); let playback_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - playback_row.set_homogeneous(true); + playback_row.set_homogeneous(false); let microphone_replay_button = gtk::Button::with_label("Replay Last 3s"); stabilize_button(µphone_replay_button, 124); - let audio_preview_heading = gtk::Label::new(Some("Local Playback / Activity")); - audio_preview_heading.add_css_class("subgroup-title"); - audio_preview_heading.set_hexpand(true); - audio_preview_heading.set_halign(gtk::Align::Start); + audio_check_meter.set_hexpand(true); + audio_check_meter.set_show_text(true); + audio_check_meter.set_text(Some("Idle")); playback_row.append(µphone_replay_button); - playback_row.append(&audio_preview_heading); + playback_row.append(&audio_check_meter); playback_body.append(&playback_row); - playback_body.append(&audio_check_meter); audio_check_detail.set_visible(false); playback_body.append(&audio_check_detail); playback_group.append(&playback_body); @@ -1195,12 +1193,14 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { root.append(&stack); let stream_status = gtk::Label::new(Some("Connect relay to preview.")); + stream_status.add_css_class("status-line"); stream_status.set_halign(gtk::Align::Start); stream_status.set_hexpand(true); stream_status.set_ellipsize(pango::EllipsizeMode::End); stream_status.set_single_line_mode(true); stream_status.set_max_width_chars(24); stream_status.set_tooltip_text(Some("Connect relay to preview.")); + root.append(&stream_status); let feed_source_combo = gtk::ComboBoxText::new(); feed_source_combo.set_tooltip_text(Some( "Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.", diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 8876bcb..e1d6b81 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -171,13 +171,17 @@ pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestCon microphone_replay_running, )); if microphone_running { + let level = tests.microphone_level_fraction().clamp(0.0, 1.0); + widgets.audio_check_meter.set_fraction(level); widgets .audio_check_meter - .set_fraction(tests.microphone_level_fraction()); + .set_text(Some(&format!("Mic {:>3}%", (level * 100.0).round() as u32))); } else if speaker_running || microphone_replay_running { + widgets.audio_check_meter.set_text(Some("Playback")); widgets.audio_check_meter.pulse(); } else { widgets.audio_check_meter.set_fraction(0.0); + widgets.audio_check_meter.set_text(Some("Idle")); } } diff --git a/server/src/video.rs b/server/src/video.rs index 51946ce..c5e87e7 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -444,12 +444,12 @@ pub async fn eye_ball_with_request( } else { format!( "v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \ - video/x-h264,width={},height={},framerate={}/1 ! \ + video/x-h264,width={},height={} ! \ queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \ h264parse disable-passthrough=true config-interval=-1 ! \ video/x-h264,stream-format=byte-stream,alignment=au ! \ appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", - request.width, request.height, request.fps, + request.width, request.height, ) };