diff --git a/Cargo.lock b/Cargo.lock index 85bd589..9dfdd10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.16.8" +version = "0.16.9" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.16.8" +version = "0.16.9" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.16.8" +version = "0.16.9" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index c522ef2..68585a1 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.16.8" +version = "0.16.9" edition = "2024" [dependencies] diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 8575b2f..efcc4db 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -64,13 +64,13 @@ include!("camera/bus_and_encoder.rs"); #[cfg(test)] mod tests { - use super::{CameraCodec, CameraConfig, resolved_capture_profile}; + use super::{CameraCodec, CameraConfig, resolved_capture_profile, resolved_output_profile}; use serial_test::serial; #[test] #[serial] - /// Guards the browser-facing UVC contract against launcher preview overrides. - fn negotiated_capture_profile_overrides_launcher_quality_env_by_default() { + /// Keeps the selected local webcam mode independent from the UVC gadget mode. + fn local_capture_profile_keeps_launcher_quality_env_by_default() { let cfg = CameraConfig { codec: CameraCodec::Mjpeg, width: 640, @@ -84,7 +84,35 @@ mod tests { ("LESAVKA_CAM_FPS", Some("30")), ("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", None), ], - || assert_eq!(resolved_capture_profile(Some(cfg)), (640, 480, 20)), + || assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)), + ); + } + + #[test] + #[serial] + /// Guards the browser-facing UVC contract against launcher quality selection. + fn negotiated_output_profile_uses_server_uvc_contract_by_default() { + let cfg = CameraConfig { + codec: CameraCodec::Mjpeg, + width: 640, + height: 480, + fps: 20, + }; + temp_env::with_vars( + [ + ("LESAVKA_CAM_WIDTH", Some("1280")), + ("LESAVKA_CAM_HEIGHT", Some("720")), + ("LESAVKA_CAM_FPS", Some("30")), + ("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", None), + ], + || { + let capture_profile = resolved_capture_profile(Some(cfg)); + assert_eq!(capture_profile, (1280, 720, 30)); + assert_eq!( + resolved_output_profile(Some(cfg), capture_profile), + (640, 480, 20) + ); + }, ); } @@ -105,7 +133,14 @@ mod tests { ("LESAVKA_CAM_FPS", Some("30")), ("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", Some("1")), ], - || assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)), + || { + let capture_profile = resolved_capture_profile(Some(cfg)); + assert_eq!(capture_profile, (1280, 720, 30)); + assert_eq!( + resolved_output_profile(Some(cfg), capture_profile), + (1280, 720, 30) + ); + }, ); } } diff --git a/client/src/input/camera/capture_pipeline.rs b/client/src/input/camera/capture_pipeline.rs index d01a093..3e8d268 100644 --- a/client/src/input/camera/capture_pipeline.rs +++ b/client/src/input/camera/capture_pipeline.rs @@ -40,10 +40,14 @@ impl CameraCapture { |cfg| matches!(cfg.codec, CameraCodec::Mjpeg), ); let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100); - let (width, height, fps) = resolved_capture_profile(cfg); + let capture_profile = resolved_capture_profile(cfg); + let (capture_width, capture_height, capture_fps) = capture_profile; + let (width, height, fps) = resolved_output_profile(cfg, capture_profile); let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps); let source_profile = camera_source_profile(allow_mjpg_source); let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg; + let passthrough_mjpg_source = + use_mjpg_source && capture_profile == (width, height, fps); let (enc, kf_prop) = if use_mjpg_source && !output_mjpeg { ("x264enc", Some("key-int-max")) } else { @@ -66,29 +70,29 @@ impl CameraCapture { } #[cfg(not(coverage))] let have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some(); - let (src_caps, preenc) = match enc { + let preenc = match enc { // ─────────────────────────────────────────────────────────────────── // Jetson (has nvvidconv) Desktop (falls back to videoconvert) // ─────────────────────────────────────────────────────────────────── #[cfg(not(coverage))] "nvh264enc" if have_nvvidconv => - (format!( - "video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1" - ), "nvvidconv !"), + format!( + "nvvidconv ! video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1 !" + ), #[cfg(not(coverage))] "nvh264enc" /* else */ => - (format!( - "video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1" - ), "videoconvert !"), + format!( + "videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !" + ), #[cfg(not(coverage))] "vaapih264enc" => - (format!( - "video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1" - ), "videoconvert !"), + format!( + "videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !" + ), _ => - (format!( - "video/x-raw,width={width},height={height},framerate={fps}/1" - ), "videoconvert !"), + format!( + "videoconvert ! video/x-raw,width={width},height={height},framerate={fps}/1 !" + ), }; // let desc = format!( @@ -104,11 +108,24 @@ impl CameraCapture { // * x264enc needs the usual raw caps. let preview_tap_path = camera_preview_tap_path(); let preview_tap_branch = camera_preview_tap_branch(width, height, fps); - let raw_source_chain = - camera_raw_source_chain(&src_desc, &src_caps, width, height, fps, source_profile); + let source_raw_caps = format!( + "video/x-raw,width={capture_width},height={capture_height},framerate={capture_fps}/1" + ); + let raw_source_chain = camera_raw_source_chain( + &src_desc, + &source_raw_caps, + capture_width, + capture_height, + capture_fps, + source_profile, + ); + let normalized_raw_chain = format!( + "{raw_source_chain} ! {}", + camera_output_raw_chain(width, height, fps) + ); let desc = if preview_tap_path.is_some() { if output_mjpeg { - if use_mjpg_source { + if passthrough_mjpg_source { format!( "{src_desc} ! \ image/jpeg,width={width},height={height},framerate={fps}/1 ! \ @@ -120,7 +137,7 @@ impl CameraCapture { ) } else { format!( - "{raw_source_chain} ! \ + "{normalized_raw_chain} ! \ tee name=t \ t. ! queue max-size-buffers=30 leaky=downstream ! \ videoconvert ! jpegenc quality={jpeg_quality} ! \ @@ -129,22 +146,9 @@ impl CameraCapture { {preview_tap_branch}" ) } - } else if use_mjpg_source { - format!( - "{src_desc} ! \ - image/jpeg,width={width},height={height} ! \ - jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \ - tee name=t \ - t. ! queue max-size-buffers=30 leaky=downstream ! \ - videoconvert ! {enc_opts} ! \ - h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true \ - t. ! queue max-size-buffers=2 leaky=downstream ! \ - {preview_tap_branch}" - ) } else { format!( - "{raw_source_chain} ! \ + "{normalized_raw_chain} ! \ tee name=t \ t. ! queue max-size-buffers=30 leaky=downstream ! \ {preenc} {enc_opts} ! \ @@ -155,7 +159,7 @@ impl CameraCapture { ) } } else if output_mjpeg { - if use_mjpg_source { + if passthrough_mjpg_source { format!( "{src_desc} ! \ image/jpeg,width={width},height={height},framerate={fps}/1 ! \ @@ -164,32 +168,32 @@ impl CameraCapture { ) } else { format!( - "{raw_source_chain} ! \ + "{normalized_raw_chain} ! \ videoconvert ! jpegenc quality={jpeg_quality} ! \ queue max-size-buffers=30 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true" ) } - } else if use_mjpg_source { - format!( - "{src_desc} ! \ - image/jpeg,width={width},height={height} ! \ - jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \ - videoconvert ! {enc_opts} ! \ - h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ - queue max-size-buffers=30 leaky=downstream ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true" - ) } else { format!( - "{raw_source_chain} ! \ + "{normalized_raw_chain} ! \ {preenc} {enc_opts} ! \ h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ queue max-size-buffers=30 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true" ) }; - tracing::info!(%enc, width, height, fps, ?desc, "📸 using encoder element"); + tracing::info!( + %enc, + capture_width, + capture_height, + capture_fps, + output_width = width, + output_height = height, + output_fps = fps, + ?desc, + "📸 using encoder element" + ); let pipeline: gst::Pipeline = gst::parse::launch(&desc) .context("gst parse_launch(cam)")? @@ -259,15 +263,12 @@ impl CameraCapture { } } -/// Resolve the exact profile the client sends, preferring the server UVC contract. +/// Resolve the profile requested from the local webcam. +/// +/// The server UVC contract is applied after capture. Keeping these separate +/// prevents a browser-facing 640x480/20 gadget mode from forcing a local webcam +/// to expose that exact mode when the selected camera quality is 720p/30. fn resolved_capture_profile(cfg: Option) -> (u32, u32, u32) { - match cfg { - Some(cfg) if !env_flag_enabled("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE") => { - return (cfg.width, cfg.height, cfg.fps.max(1)); - } - _ => {} - } - ( env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)), env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)), @@ -275,6 +276,19 @@ fn resolved_capture_profile(cfg: Option) -> (u32, u32, u32) { ) } +/// Resolve the profile emitted toward the remote UVC gadget. +fn resolved_output_profile( + cfg: Option, + capture_profile: (u32, u32, u32), +) -> (u32, u32, u32) { + match cfg { + Some(cfg) if !env_flag_enabled("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE") => { + (cfg.width, cfg.height, cfg.fps.max(1)) + } + _ => capture_profile, + } +} + fn env_flag_enabled(name: &str) -> bool { std::env::var(name).ok().is_some_and(|value| { let trimmed = value.trim(); diff --git a/client/src/input/camera/source_description.rs b/client/src/input/camera/source_description.rs index 3c31f6e..083495a 100644 --- a/client/src/input/camera/source_description.rs +++ b/client/src/input/camera/source_description.rs @@ -56,6 +56,14 @@ fn camera_auto_decode_caps(width: u32, height: u32, fps: u32) -> String { ) } +/// Convert local webcam frames into the exact outbound UVC profile. +fn camera_output_raw_chain(width: u32, height: u32, fps: u32) -> String { + format!( + "videoconvert ! videoscale ! videorate ! \ + video/x-raw,width={width},height={height},framerate={fps}/1,pixel-aspect-ratio=1/1" + ) +} + fn camera_preview_tap_path() -> Option { std::env::var(CAMERA_PREVIEW_TAP_ENV) .ok() diff --git a/client/src/launcher/ui/media_device_bindings.rs b/client/src/launcher/ui/media_device_bindings.rs index ef5d55a..a6a55a7 100644 --- a/client/src/launcher/ui/media_device_bindings.rs +++ b/client/src/launcher/ui/media_device_bindings.rs @@ -10,12 +10,17 @@ state .borrow_mut() .select_microphone(selected_combo_value(µphone_combo_read)); - if tests.borrow_mut().is_running(DeviceTestKind::Microphone) { + let relay_live = child_proc.borrow().is_some(); + if relay_live { + widgets.status_label.set_text( + "Microphone selection staged for the next relay launch. Use the Mic toggle to soft-pause or resume the current live feed.", + ); + } else if tests.borrow_mut().is_running(DeviceTestKind::Microphone) { widgets.status_label.set_text( "Microphone selection changed. Restart Monitor Mic to audition the new input.", ); } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_launcher_ui(&widgets, &state.borrow(), relay_live); refresh_test_buttons(&widgets, &mut tests.borrow_mut()); }); } @@ -34,12 +39,17 @@ let speaker_running = tests.borrow_mut().is_running(DeviceTestKind::Speaker); let microphone_running = tests.borrow_mut().is_running(DeviceTestKind::Microphone); - if speaker_running || microphone_running { + let relay_live = child_proc.borrow().is_some(); + if relay_live { + widgets.status_label.set_text( + "Speaker selection staged for the next relay launch. Speaker gain still applies live.", + ); + } else if speaker_running || microphone_running { widgets.status_label.set_text( "Speaker selection changed. Restart the local audio tests to hear the new output.", ); } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_launcher_ui(&widgets, &state.borrow(), relay_live); refresh_test_buttons(&widgets, &mut tests.borrow_mut()); }); } diff --git a/client/src/launcher/ui/stage_device_bindings.rs b/client/src/launcher/ui/stage_device_bindings.rs index 463639b..e170ea3 100644 --- a/client/src/launcher/ui/stage_device_bindings.rs +++ b/client/src/launcher/ui/stage_device_bindings.rs @@ -30,6 +30,10 @@ widgets .status_label .set_text(&format!("Camera quality update failed: {err}")); + } else if child_proc.borrow().is_some() { + widgets.status_label.set_text( + "Camera selection staged for the next relay launch. Use the Camera toggle to soft-pause or resume the current live feed.", + ); } else if preview_was_running { widgets.status_label.set_text(&format!( "Local camera preview switched to {}{}.", @@ -68,6 +72,10 @@ widgets .status_label .set_text(&format!("Camera quality update failed: {err}")); + } else if child_proc.borrow().is_some() { + widgets.status_label.set_text( + "Camera quality staged for the next relay launch. The live feed keeps its current capture pipeline.", + ); } else if preview_was_running { widgets.status_label.set_text(&format!( "Local camera preview switched to {}.", diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs index 114eebb..5f91ce9 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -235,8 +235,8 @@ console_level_combo.set_tooltip_text(Some("Show relay logs at this level or higher.")); let console_copy_button = gtk::Button::with_label("Copy"); console_copy_button.set_tooltip_text(Some("Copy visible log.")); - let console_popout_button = gtk::Button::with_label("Pop Out"); - console_popout_button.set_tooltip_text(Some("Open log window.")); + let console_popout_button = gtk::Button::with_label("Break Out"); + console_popout_button.set_tooltip_text(Some("Break out the log window.")); let console_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); console_buttons.set_hexpand(true); console_buttons.set_homogeneous(true); diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index cf2b9ca..013c2f6 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -171,25 +171,24 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .uvc_recover_button .set_sensitive(state.server_available); - widgets.device_refresh_button.set_sensitive(!relay_live); + widgets.device_refresh_button.set_sensitive(true); widgets .camera_combo - .set_sensitive(!relay_live && state.channels.camera); + .set_sensitive(state.channels.camera); widgets.camera_quality_combo.set_sensitive( - !relay_live - && state.channels.camera + state.channels.camera && state.devices.camera.is_some() && state.camera_quality.is_some(), ); widgets .microphone_combo - .set_sensitive(!relay_live && state.channels.microphone); + .set_sensitive(state.channels.microphone); widgets .speaker_combo - .set_sensitive(!relay_live && state.channels.audio); + .set_sensitive(state.channels.audio); widgets .audio_gain_scale - .set_sensitive(!relay_live && state.channels.audio); + .set_sensitive(state.channels.audio); widgets.keyboard_combo.set_sensitive(!relay_live); widgets.mouse_combo.set_sensitive(!relay_live); widgets @@ -223,7 +222,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi .set_sensitive(!relay_live && state.channels.microphone); widgets .mic_gain_scale - .set_sensitive(!relay_live && state.channels.microphone); + .set_sensitive(state.channels.microphone); widgets .speaker_test_button .set_sensitive(!relay_live && state.channels.audio); diff --git a/common/Cargo.toml b/common/Cargo.toml index 4fc2022..e8b237f 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.16.8" +version = "0.16.9" edition = "2024" build = "build.rs" diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index ae8b203..2703297 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -58,7 +58,7 @@ "client/src/input/camera.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 111 + "loc": 146 }, "client/src/input/camera/bus_and_encoder.rs": { "clippy_warnings": 0, @@ -68,7 +68,7 @@ "client/src/input/camera/capture_pipeline.rs": { "clippy_warnings": 0, "doc_debt": 4, - "loc": 320 + "loc": 334 }, "client/src/input/camera/device_selection.rs": { "clippy_warnings": 0, @@ -88,7 +88,7 @@ "client/src/input/camera/source_description.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 76 + "loc": 84 }, "client/src/input/inputs.rs": { "clippy_warnings": 0, @@ -338,7 +338,7 @@ "client/src/launcher/ui/media_device_bindings.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 182 + "loc": 192 }, "client/src/launcher/ui/message_and_network_state.rs": { "clippy_warnings": 0, @@ -373,7 +373,7 @@ "client/src/launcher/ui/stage_device_bindings.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 176 + "loc": 184 }, "client/src/launcher/ui/startup_window_guard.rs": { "clippy_warnings": 0, diff --git a/scripts/ci/media_reliability_gate.sh b/scripts/ci/media_reliability_gate.sh index e8022f1..b5b045e 100755 --- a/scripts/ci/media_reliability_gate.sh +++ b/scripts/ci/media_reliability_gate.sh @@ -47,7 +47,18 @@ MEDIA_TESTS=( start_seconds=$(date +%s) status=0 set +e -cargo test -p lesavka_testing "${MEDIA_TESTS[@]}" --color never 2>&1 | tee "${TEST_LOG}" +{ + echo '==> client camera profile/unit guards' + cargo test -p lesavka_client --color never input::camera::tests -- --nocapture + camera_status=${PIPESTATUS[0]} + echo + echo '==> media reliability contract tests' + cargo test -p lesavka_testing --color never "${MEDIA_TESTS[@]}" + contract_status=${PIPESTATUS[0]} + if [[ "${camera_status}" -ne 0 || "${contract_status}" -ne 0 ]]; then + exit 1 + fi +} 2>&1 | tee "${TEST_LOG}" status=${PIPESTATUS[0]} set -e end_seconds=$(date +%s) diff --git a/server/Cargo.toml b/server/Cargo.toml index 075ae97..67e371e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.16.8" +version = "0.16.9" edition = "2024" autobins = false diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index 7e4e3a7..750ef57 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -19,6 +19,7 @@ const UI_SRC: &str = concat!( include_str!("../../client/src/launcher/ui/activation_setup.rs"), include_str!("../../client/src/launcher/ui/device_refresh_binding.rs"), include_str!("../../client/src/launcher/ui/local_test_bindings.rs"), + include_str!("../../client/src/launcher/ui/media_device_bindings.rs"), include_str!("../../client/src/launcher/ui/relay_input_bindings.rs"), include_str!("../../client/src/launcher/ui/runtime_poll.rs"), include_str!("../../client/src/launcher/ui/stage_device_bindings.rs"), @@ -68,29 +69,26 @@ fn relay_child_starts_safe_parent_watchdog_on_boot() { } #[test] -fn relay_address_entry_is_locked_while_relay_is_live() { +fn relay_address_locks_but_media_staging_stays_available_while_relay_is_live() { assert!(UI_RUNTIME_SRC.contains("widgets.server_entry.set_sensitive(!relay_live);")); assert!( - UI_RUNTIME_SRC.contains( - ".camera_combo\n .set_sensitive(!relay_live && state.channels.camera);" - ) + UI_RUNTIME_SRC.contains(".camera_combo\n .set_sensitive(state.channels.camera);") ); assert!(UI_RUNTIME_SRC.contains(".camera_quality_combo")); assert!(UI_RUNTIME_SRC.contains("widgets.camera_quality_combo.set_sensitive(")); assert!(UI_RUNTIME_SRC.contains("state.devices.camera.is_some()")); assert!(UI_RUNTIME_SRC.contains("state.camera_quality.is_some()")); - assert!(UI_RUNTIME_SRC.contains( - ".microphone_combo\n .set_sensitive(!relay_live && state.channels.microphone);" - )); assert!( - UI_RUNTIME_SRC.contains( - ".speaker_combo\n .set_sensitive(!relay_live && state.channels.audio);" - ) + UI_RUNTIME_SRC + .contains(".microphone_combo\n .set_sensitive(state.channels.microphone);") + ); + assert!( + UI_RUNTIME_SRC.contains(".speaker_combo\n .set_sensitive(state.channels.audio);") ); assert!(UI_RUNTIME_SRC.contains(".audio_gain_scale")); - assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.audio);")); + assert!(UI_RUNTIME_SRC.contains(".set_sensitive(state.channels.audio);")); assert!(UI_RUNTIME_SRC.contains(".mic_gain_scale")); - assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.microphone);")); + assert!(UI_RUNTIME_SRC.contains(".set_sensitive(state.channels.microphone);")); assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);")); assert!( @@ -108,6 +106,10 @@ fn relay_address_entry_is_locked_while_relay_is_live() { ) ); assert!(UI_RUNTIME_SRC.contains("Soft-pause or resume this feed in the running relay")); + assert!(UI_SRC.contains("Camera selection staged for the next relay launch")); + assert!(UI_SRC.contains("Camera quality staged for the next relay launch")); + assert!(UI_SRC.contains("Microphone selection staged for the next relay launch")); + assert!(UI_SRC.contains("Speaker selection staged for the next relay launch")); assert!(UI_RUNTIME_SRC.contains("\"Connect\"")); assert!(UI_RUNTIME_SRC.contains("\"Disconnect\"")); }