diff --git a/Cargo.lock b/Cargo.lock index 723bb0d..4387f49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.21.15" +version = "0.21.16" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.21.15" +version = "0.21.16" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.21.15" +version = "0.21.16" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 181cedb..c2ddcaf 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.21.15" +version = "0.21.16" edition = "2024" [dependencies] diff --git a/client/src/app_support.rs b/client/src/app_support.rs index af59373..75dc69d 100644 --- a/client/src/app_support.rs +++ b/client/src/app_support.rs @@ -22,7 +22,10 @@ pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String { #[must_use] /// Build local camera capture settings from negotiated peer capabilities. pub fn camera_config_from_caps(caps: &PeerCaps) -> Option { - let codec = parse_camera_codec(caps.camera_codec.as_deref()?)?; + let codec = std::env::var("LESAVKA_CAM_CODEC") + .ok() + .and_then(|value| parse_camera_codec(&value)) + .or_else(|| parse_camera_codec(caps.camera_codec.as_deref()?))?; Some(CameraConfig { codec, width: caps.camera_width?, @@ -63,6 +66,7 @@ mod tests { }; use crate::handshake::PeerCaps; use crate::input::camera::CameraCodec; + use serial_test::serial; use std::time::Duration; #[test] @@ -94,14 +98,47 @@ mod tests { } #[test] + #[serial] fn camera_config_from_caps_requires_complete_profile() { - let mut caps = PeerCaps { + temp_env::with_var("LESAVKA_CAM_CODEC", None::<&str>, || { + let mut caps = PeerCaps { + camera: true, + microphone: false, + bundled_webcam_media: false, + server_version: None, + camera_output: Some(String::from("uvc")), + camera_codec: Some(String::from("mjpeg")), + camera_width: Some(1280), + camera_height: Some(720), + camera_fps: Some(25), + eye_width: None, + eye_height: None, + eye_fps: None, + }; + + let config = camera_config_from_caps(&caps).expect("complete caps should map"); + assert!(matches!(config.codec, CameraCodec::Mjpeg)); + assert_eq!(config.width, 1280); + + caps.camera_codec = Some(String::from("h265")); + let config = camera_config_from_caps(&caps).expect("h265 alias should map"); + assert!(matches!(config.codec, CameraCodec::Hevc)); + + caps.camera_codec = Some(String::from("vp9")); + assert!(camera_config_from_caps(&caps).is_none()); + }); + } + + #[test] + #[serial] + fn camera_config_from_caps_honors_launcher_codec_override() { + let caps = PeerCaps { camera: true, microphone: false, bundled_webcam_media: false, server_version: None, camera_output: Some(String::from("uvc")), - camera_codec: Some(String::from("mjpeg")), + camera_codec: Some(String::from("hevc")), camera_width: Some(1280), camera_height: Some(720), camera_fps: Some(25), @@ -110,16 +147,10 @@ mod tests { eye_fps: None, }; - let config = camera_config_from_caps(&caps).expect("complete caps should map"); - assert!(matches!(config.codec, CameraCodec::Mjpeg)); - assert_eq!(config.width, 1280); - - caps.camera_codec = Some(String::from("h265")); - let config = camera_config_from_caps(&caps).expect("h265 alias should map"); - assert!(matches!(config.codec, CameraCodec::Hevc)); - - caps.camera_codec = Some(String::from("vp9")); - assert!(camera_config_from_caps(&caps).is_none()); + temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || { + let config = camera_config_from_caps(&caps).expect("override should map"); + assert!(matches!(config.codec, CameraCodec::Mjpeg)); + }); } #[test] diff --git a/client/src/launcher/diagnostics/diagnostics_models.rs b/client/src/launcher/diagnostics/diagnostics_models.rs index 78fb572..5f7b6ae 100644 --- a/client/src/launcher/diagnostics/diagnostics_models.rs +++ b/client/src/launcher/diagnostics/diagnostics_models.rs @@ -154,6 +154,7 @@ pub struct SnapshotReport { pub right_rendered_caps_label: String, pub selected_camera: Option, pub camera_quality_label: String, + pub upstream_camera_transport: String, pub selected_microphone: Option, pub selected_speaker: Option, pub media_channels: MediaChannelState, diff --git a/client/src/launcher/diagnostics/snapshot_report.rs b/client/src/launcher/diagnostics/snapshot_report.rs index b05a9a9..1787c15 100644 --- a/client/src/launcher/diagnostics/snapshot_report.rs +++ b/client/src/launcher/diagnostics/snapshot_report.rs @@ -228,6 +228,7 @@ impl SnapshotReport { .camera_quality .map(CameraMode::short_label) .unwrap_or_else(|| "default".to_string()), + upstream_camera_transport: state.webcam_transport.label().to_string(), selected_microphone: state.devices.microphone.clone(), selected_speaker: state.devices.speaker.clone(), media_channels: MediaChannelState { diff --git a/client/src/launcher/diagnostics/snapshot_report_text.rs b/client/src/launcher/diagnostics/snapshot_report_text.rs index 0ac1069..ad07e31 100644 --- a/client/src/launcher/diagnostics/snapshot_report_text.rs +++ b/client/src/launcher/diagnostics/snapshot_report_text.rs @@ -90,9 +90,10 @@ impl SnapshotReport { let _ = writeln!(text, "media staging"); let _ = writeln!( text, - " camera: {} | quality={} | enabled={}", + " camera: {} | quality={} | transport={} | enabled={}", self.selected_camera.as_deref().unwrap_or("auto"), self.camera_quality_label, + self.upstream_camera_transport, self.media_channels.camera ); let _ = writeln!( diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index 4eaa352..816a214 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -159,6 +159,10 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap { state.audio_gain_env_value(), ); envs.insert("LESAVKA_MIC_GAIN".to_string(), state.mic_gain_env_value()); + envs.insert( + "LESAVKA_CAM_CODEC".to_string(), + state.webcam_transport.env_value().to_string(), + ); if matches!(state.view_mode, ViewMode::Unified) { envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string()); } diff --git a/client/src/launcher/state/launcher_state_impl.rs b/client/src/launcher/state/launcher_state_impl.rs index 8fd4c52..88a8436 100644 --- a/client/src/launcher/state/launcher_state_impl.rs +++ b/client/src/launcher/state/launcher_state_impl.rs @@ -310,6 +310,10 @@ impl LauncherState { self.camera_quality = mode; } + pub fn select_webcam_transport(&mut self, transport: WebcamTransport) { + self.webcam_transport = transport; + } + pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec { self.devices .camera diff --git a/client/src/launcher/state/launcher_status_line.rs b/client/src/launcher/state/launcher_status_line.rs index 1f4dd2c..f89ae65 100644 --- a/client/src/launcher/state/launcher_status_line.rs +++ b/client/src/launcher/state/launcher_status_line.rs @@ -1,7 +1,7 @@ impl LauncherState { pub fn status_line(&self) -> String { format!( - "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}", + "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} camera_transport={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}", self.server_available, match self.routing { InputRouting::Local => "local", @@ -27,6 +27,7 @@ impl LauncherState { self.camera_quality .map(CameraMode::short_label) .unwrap_or_else(|| "default".to_string()), + self.webcam_transport.label(), media_status_label(self.channels.microphone, self.devices.microphone.as_deref()), media_status_label(self.channels.audio, self.devices.speaker.as_deref()), self.channels.camera, diff --git a/client/src/launcher/state/selection_models.rs b/client/src/launcher/state/selection_models.rs index bae40ba..a3309a5 100644 --- a/client/src/launcher/state/selection_models.rs +++ b/client/src/launcher/state/selection_models.rs @@ -27,6 +27,66 @@ impl InputRouting { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum WebcamTransport { + Hevc, + Mjpeg, +} + +impl WebcamTransport { + /// Return the stable GTK row id for the upstream webcam transport. + /// + /// Inputs: the selected transport. Output: the id stored in the combo box. + /// Why: the UI must map operator intent onto the exact codec environment + /// variable the relay child consumes when it starts its webcam pipeline. + pub const fn as_id(self) -> &'static str { + match self { + Self::Hevc => "hevc", + Self::Mjpeg => "mjpeg", + } + } + + /// Parse a GTK row id back into an upstream webcam transport. + /// + /// Inputs: raw combo id. Output: a supported transport when recognized. + /// Why: unsupported codecs should never silently enter launcher state, + /// since the server decoder path is calibrated per ingress codec. + pub fn from_id(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "hevc" | "h265" | "h.265" => Some(Self::Hevc), + "mjpeg" | "mjpg" | "jpeg" => Some(Self::Mjpeg), + _ => None, + } + } + + /// Return the environment value consumed by the relay child. + /// + /// Inputs: the selected transport. Output: canonical `LESAVKA_CAM_CODEC`. + /// Why: the launcher and headless client share the same camera pipeline + /// selector, so keeping this value canonical prevents HEVC/MJPEG drift. + pub const fn env_value(self) -> &'static str { + self.as_id() + } + + /// Short label displayed in the launcher dropdown. + /// + /// Inputs: the selected transport. Output: a human-readable label. Why: + /// this makes the compact control understandable without implying YUY2 or + /// H.264 are ready for the current calibrated upstream path. + pub const fn label(self) -> &'static str { + match self { + Self::Hevc => "HEVC", + Self::Mjpeg => "MJPEG", + } + } +} + +impl Default for WebcamTransport { + fn default() -> Self { + Self::Hevc + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ViewMode { Unified, diff --git a/client/src/launcher/state/selection_models/sync_and_state_status.rs b/client/src/launcher/state/selection_models/sync_and_state_status.rs index a31103d..a07c008 100644 --- a/client/src/launcher/state/selection_models/sync_and_state_status.rs +++ b/client/src/launcher/state/selection_models/sync_and_state_status.rs @@ -276,6 +276,7 @@ pub struct LauncherState { pub breakout_sizes: [BreakoutSizePreset; 2], pub devices: DeviceSelection, pub camera_quality: Option, + pub webcam_transport: WebcamTransport, pub channels: ChannelSelection, pub audio_gain_percent: u32, pub mic_gain_percent: u32, @@ -313,6 +314,7 @@ impl Default for LauncherState { breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], devices: DeviceSelection::default(), camera_quality: None, + webcam_transport: WebcamTransport::default(), channels: ChannelSelection::default(), audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT, mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT, diff --git a/client/src/launcher/tests/diagnostics.rs b/client/src/launcher/tests/diagnostics.rs index df68c59..7144315 100644 --- a/client/src/launcher/tests/diagnostics.rs +++ b/client/src/launcher/tests/diagnostics.rs @@ -1,6 +1,7 @@ use super::*; use crate::launcher::state::{ CaptureSizePreset, DeviceSelection, DisplaySurface, FeedSourcePreset, LauncherState, + WebcamTransport, }; use crate::uplink_telemetry::UpstreamStreamTelemetry; @@ -157,6 +158,7 @@ fn snapshot_report_contains_state_fields_and_samples() { assert!(report.left_stream_caps_label.contains("video/x-h264")); assert!(report.left_decoded_caps_label.contains("video/x-raw")); assert!(report.left_rendered_caps_label.contains("video/x-raw")); + assert_eq!(report.upstream_camera_transport, "HEVC"); assert_eq!(report.upstream_camera.queue_peak, 7); assert_eq!(report.upstream_microphone.reconnect_count, 1); } @@ -240,6 +242,7 @@ fn snapshot_text_reflects_live_media_control_changes() { state.select_camera_quality(Some(crate::launcher::devices::CameraMode::new( 1920, 1080, 30, ))); + state.select_webcam_transport(WebcamTransport::Mjpeg); state.select_microphone(Some("alsa_input.usb".to_string())); state.select_speaker(Some("alsa_output.usb".to_string())); state.set_audio_gain_percent(250); @@ -254,7 +257,9 @@ fn snapshot_text_reflects_live_media_control_changes() { ); let text = report.to_pretty_text(); - assert!(text.contains("camera: /dev/video9 | quality=1080p@30 | enabled=false")); + assert!( + text.contains("camera: /dev/video9 | quality=1080p@30 | transport=MJPEG | enabled=false") + ); assert!(text.contains("speaker: alsa_output.usb | volume=250% | enabled=true")); assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true")); } diff --git a/client/src/launcher/tests/mod.rs b/client/src/launcher/tests/mod.rs index b00f1fe..fa467c2 100644 --- a/client/src/launcher/tests/mod.rs +++ b/client/src/launcher/tests/mod.rs @@ -1,4 +1,5 @@ use super::*; +use crate::launcher::state::WebcamTransport; use serial_test::serial; #[cfg(not(coverage))] @@ -161,6 +162,7 @@ fn runtime_env_vars_emit_selected_controls() { ); assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string())); assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string())); + assert_eq!(envs.get("LESAVKA_CAM_CODEC"), Some(&"hevc".to_string())); assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string())); assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string())); assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string())); @@ -252,6 +254,21 @@ fn runtime_env_vars_keeps_remote_failsafe_disabled_for_invalid_launch_option() { }); } +#[test] +fn runtime_env_vars_emit_selected_webcam_transport() { + let mut state = LauncherState::new(); + assert_eq!( + runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"), + Some(&"hevc".to_string()) + ); + + state.select_webcam_transport(WebcamTransport::Mjpeg); + assert_eq!( + runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"), + Some(&"mjpeg".to_string()) + ); +} + #[test] fn runtime_env_vars_do_not_disable_breakout_video_windows() { let mut state = LauncherState::new(); diff --git a/client/src/launcher/tests/state.rs b/client/src/launcher/tests/state.rs index 3c65fa5..312d7df 100644 --- a/client/src/launcher/tests/state.rs +++ b/client/src/launcher/tests/state.rs @@ -8,6 +8,20 @@ fn routing_and_view_env_values_are_stable() { assert_eq!(ViewMode::Breakout.as_env(), "breakout"); assert_eq!(DisplaySurface::Preview.label(), "preview"); assert_eq!(DisplaySurface::Window.label(), "window"); + assert_eq!(WebcamTransport::default(), WebcamTransport::Hevc); + assert_eq!(WebcamTransport::Hevc.as_id(), "hevc"); + assert_eq!(WebcamTransport::Mjpeg.as_id(), "mjpeg"); + assert_eq!(WebcamTransport::Hevc.env_value(), "hevc"); + assert_eq!(WebcamTransport::Mjpeg.label(), "MJPEG"); + assert_eq!( + WebcamTransport::from_id("h265"), + Some(WebcamTransport::Hevc) + ); + assert_eq!( + WebcamTransport::from_id("jpeg"), + Some(WebcamTransport::Mjpeg) + ); + assert_eq!(WebcamTransport::from_id("yuy2"), None); } #[test] diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 47a76e2..d5d7d55 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -2,7 +2,7 @@ use super::*; use crate::launcher::{ devices::{CameraMode, DeviceCatalog}, preview::PreviewBinding, - state::{BreakoutSizePreset, LauncherState, PreviewSourceSize}, + state::{BreakoutSizePreset, LauncherState, PreviewSourceSize, WebcamTransport}, ui_components::build_launcher_view, }; use crate::uplink_telemetry::UpstreamStreamTelemetry; @@ -322,6 +322,59 @@ fn recovery_buttons_use_upstream_device_labels() { ); } +#[gtk::test] +#[serial] +fn webcam_transport_combo_tracks_selected_upstream_codec() { + if gtk::gdk::Display::default().is_none() { + return; + } + + let app = gtk::Application::builder() + .application_id("dev.lesavka.test-webcam-transport") + .build(); + let _ = app.register(None::<>k::gio::Cancellable>); + + let mut state = LauncherState::new(); + state.set_camera_channel_enabled(true); + let view = build_launcher_view( + &app, + "http://127.0.0.1:50051", + &DeviceCatalog::default(), + &state, + ); + + assert_eq!( + view.device_stage + .webcam_transport_combo + .active_id() + .as_deref(), + Some("hevc") + ); + assert!(view.device_stage.webcam_transport_combo.is_sensitive()); + + state.select_webcam_transport(WebcamTransport::Mjpeg); + refresh_launcher_ui(&view.widgets, &state, false); + assert_eq!( + view.device_stage + .webcam_transport_combo + .active_id() + .as_deref(), + Some("mjpeg") + ); + assert!(view.device_stage.webcam_transport_combo.is_sensitive()); + + refresh_launcher_ui(&view.widgets, &state, true); + assert!(!view.device_stage.webcam_transport_combo.is_sensitive()); + assert!( + view.device_stage + .webcam_transport_combo + .tooltip_text() + .as_deref() + .unwrap_or_default() + .contains("Reconnect") + ); +} + #[gtk::test] #[serial] fn diagnostics_and_log_popouts_install_native_window_chrome() { @@ -515,7 +568,7 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() { assert_eq!( recovery_uvc_health(&state, false, None), - (StatusLightState::Live, "MJPEG".to_string()) + (StatusLightState::Live, "HEVC".to_string()) ); assert_eq!( recovery_uvc_health(&state, true, None), @@ -531,6 +584,12 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() { queue_depth: 3, ..UpstreamStreamTelemetry::default() }; + assert_eq!( + recovery_uvc_health(&state, true, Some(&healthy)), + (StatusLightState::Live, "HEVC".to_string()) + ); + + state.select_webcam_transport(WebcamTransport::Mjpeg); assert_eq!( recovery_uvc_health(&state, true, Some(&healthy)), (StatusLightState::Live, "MJPEG".to_string()) diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index ccea78c..f5a7fe3 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -20,7 +20,7 @@ use { super::state::{ BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset, DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT, - MAX_MIC_GAIN_PERCENT, UpstreamSyncStatus, + MAX_MIC_GAIN_PERCENT, UpstreamSyncStatus, WebcamTransport, }, super::ui_components::{ ConsoleLogLevel, build_launcher_view, sync_camera_quality_combo, sync_input_device_combo, diff --git a/client/src/launcher/ui/stage_device_bindings.rs b/client/src/launcher/ui/stage_device_bindings.rs index 6238ae2..4c4a884 100644 --- a/client/src/launcher/ui/stage_device_bindings.rs +++ b/client/src/launcher/ui/stage_device_bindings.rs @@ -51,6 +51,35 @@ }); } + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let transport_combo = widgets.webcam_transport_combo.clone(); + let transport_combo_read = transport_combo.clone(); + transport_combo.connect_changed(move |_| { + let selected = transport_combo_read + .active_id() + .as_deref() + .and_then(WebcamTransport::from_id) + .unwrap_or_default(); + state.borrow_mut().select_webcam_transport(selected); + let relay_live = child_proc.borrow().is_some(); + if relay_live { + widgets.status_label.set_text(&format!( + "Webcam transport changed to {} for the next reconnect; keeping the live decoder path stable.", + selected.label() + )); + } else { + widgets.status_label.set_text(&format!( + "Webcam transport set to {} for the next relay launch.", + selected.label() + )); + } + refresh_launcher_ui(&widgets, &state.borrow(), relay_live); + }); + } + { let state = Rc::clone(&state); let widgets = widgets.clone(); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index cb52256..e44fe9f 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -9,7 +9,7 @@ use super::{ preview::{LauncherPreview, PreviewBinding, PreviewSurface}, state::{ BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset, - FeedSourceChoice, FeedSourcePreset, LauncherState, + FeedSourceChoice, FeedSourcePreset, LauncherState, WebcamTransport, }, }; diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index 03c9a05..e160be7 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -330,14 +330,14 @@ camera_preview_shell.append(&camera_preview_stack); 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); + for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] { + webcam_transport_combo.append(Some(transport.as_id()), transport.label()); + } + webcam_transport_combo.set_active_id(Some(state.webcam_transport.as_id())); + webcam_transport_combo.set_sensitive(true); 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.", + "Upstream webcam transport for the next relay connection. HEVC is the low-latency default; MJPEG is the calibrated fallback.", )); let webcam_group = build_subgroup_with_action("Webcam Preview", Some(webcam_transport_combo.upcast_ref())); diff --git a/client/src/launcher/ui_runtime/status_details.rs b/client/src/launcher/ui_runtime/status_details.rs index 7d8ffa4..47fb033 100644 --- a/client/src/launcher/ui_runtime/status_details.rs +++ b/client/src/launcher/ui_runtime/status_details.rs @@ -162,7 +162,7 @@ fn recovery_uac_health( media_stream_health(stream, MediaStreamKind::Microphone) } -/// Summarize whether the UVC camera function is advertised with the expected codec. +/// Summarize whether the UVC camera function is advertised with the selected upstream codec. fn recovery_uvc_health( state: &LauncherState, relay_live: bool, @@ -171,11 +171,7 @@ fn recovery_uvc_health( if !state.server_available { return (StatusLightState::Idle, "Offline".to_string()); } - let codec = state - .server_camera_codec - .as_deref() - .map(|value| value.to_ascii_uppercase()) - .unwrap_or_else(|| "READY".to_string()); + let codec = state.webcam_transport.label().to_string(); if state.server_camera == Some(false) { return (StatusLightState::Warning, "Missing".to_string()); } diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index 013c2f6..be85ac3 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -226,7 +226,23 @@ 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); + if widgets.webcam_transport_combo.active_id().as_deref() + != Some(state.webcam_transport.as_id()) + { + widgets + .webcam_transport_combo + .set_active_id(Some(state.webcam_transport.as_id())); + } + widgets + .webcam_transport_combo + .set_sensitive(!relay_live && state.channels.camera); + widgets + .webcam_transport_combo + .set_tooltip_text(Some(if relay_live { + "Reconnect before changing the upstream webcam transport; the server decoder is calibrated per ingress codec." + } else { + "Choose HEVC for low-latency upstream video or MJPEG as the calibrated fallback for the next relay launch." + })); 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 61bff1c..abc73f5 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.21.15" +version = "0.21.16" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 4c712b7..2ffc55d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.21.15" +version = "0.21.16" edition = "2024" autobins = false