diff --git a/client/assets/placeholders/eye_open_left.png b/client/assets/placeholders/eye_open_left.png index 669b144..ca08012 100644 Binary files a/client/assets/placeholders/eye_open_left.png and b/client/assets/placeholders/eye_open_left.png differ diff --git a/client/assets/placeholders/eye_open_right.png b/client/assets/placeholders/eye_open_right.png index 60a5f12..95ef77e 100644 Binary files a/client/assets/placeholders/eye_open_right.png and b/client/assets/placeholders/eye_open_right.png differ diff --git a/client/assets/placeholders/webcam_disabled.png b/client/assets/placeholders/webcam_disabled.png new file mode 100644 index 0000000..4784f33 Binary files /dev/null and b/client/assets/placeholders/webcam_disabled.png differ diff --git a/client/src/launcher/device_test/controller.rs b/client/src/launcher/device_test/controller.rs index 2ca6baa..e83955d 100644 --- a/client/src/launcher/device_test/controller.rs +++ b/client/src/launcher/device_test/controller.rs @@ -74,10 +74,15 @@ impl DeviceTestController { camera_picture: >k::Picture, camera_status: >k::Label, ) -> Result<()> { + let mirrored = self + .camera + .as_ref() + .is_some_and(LocalCameraPreview::mirrored); if let Some(camera) = self.camera.as_mut() { camera.stop(); } let mut preview = LocalCameraPreview::new(camera_picture, camera_status); + preview.set_mirrored(mirrored); preview.set_selected(self.selected_camera.as_deref())?; preview.set_selected_mode(self.selected_camera_mode)?; self.camera = Some(preview); @@ -129,6 +134,24 @@ impl DeviceTestController { preview.toggle() } + pub fn stop_camera_preview(&mut self) { + if let Some(camera) = self.camera.as_mut() { + camera.stop(); + } + } + + pub fn set_camera_preview_mirrored(&mut self, mirrored: bool) { + if let Some(camera) = self.camera.as_mut() { + camera.set_mirrored(mirrored); + } + } + + pub fn camera_preview_mirrored(&self) -> bool { + self.camera + .as_ref() + .is_some_and(LocalCameraPreview::mirrored) + } + pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result { self.cleanup_finished(); if self.microphone.is_some() { @@ -146,6 +169,10 @@ impl DeviceTestController { Ok(true) } + pub fn stop_microphone_monitor(&mut self) { + self.stop(DeviceTestKind::Microphone); + } + pub fn stop_local_capture_for_relay(&mut self) { if self .camera @@ -215,6 +242,12 @@ impl DeviceTestController { self.toggle_child(DeviceTestKind::Speaker, build_speaker_test(sink)) } + pub fn stop_speaker_test(&mut self) { + if self.speaker.is_some() { + self.stop(DeviceTestKind::Speaker); + } + } + pub fn toggle_microphone_replay(&mut self, sink: Option<&str>) -> Result { self.cleanup_finished(); if self.microphone_replay.is_some() { @@ -231,6 +264,12 @@ impl DeviceTestController { Ok(true) } + pub fn stop_microphone_replay(&mut self) { + if self.microphone_replay.is_some() { + self.stop(DeviceTestKind::MicrophoneReplay); + } + } + pub fn microphone_level_fraction(&mut self) -> f64 { self.cleanup_finished(); self.microphone_level @@ -370,10 +409,12 @@ impl DeviceTestController { } struct LocalCameraPreview { + picture: gtk::Picture, latest: Arc>>, status_text: Arc>, generation: Arc, running: Arc, + mirrored: Arc, selected_device: Option, selected_mode: Option, relay_preview_path: Option, diff --git a/client/src/launcher/device_test/local_preview.rs b/client/src/launcher/device_test/local_preview.rs index 5584af1..45ebfb7 100644 --- a/client/src/launcher/device_test/local_preview.rs +++ b/client/src/launcher/device_test/local_preview.rs @@ -4,17 +4,22 @@ impl LocalCameraPreview { let status_text = Arc::new(Mutex::new(CAMERA_PREVIEW_IDLE.to_string())); let generation = Arc::new(AtomicU64::new(0)); let running = Arc::new(AtomicBool::new(false)); + let mirrored = Arc::new(AtomicBool::new(false)); - picture.set_paintable(Some(&blank_camera_preview_texture())); + picture.set_paintable(Some(&camera_preview_placeholder_texture())); { let picture = picture.clone(); let status_label = status_label.clone(); let latest = Arc::clone(&latest); let status_text = Arc::clone(&status_text); + let mirrored = Arc::clone(&mirrored); 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 { + if let Some(mut frame) = next { + if mirrored.load(Ordering::Acquire) { + mirror_preview_frame(&mut frame); + } let bytes = glib::Bytes::from_owned(frame.rgba); let texture = gdk::MemoryTexture::new( frame.width, @@ -33,10 +38,12 @@ impl LocalCameraPreview { } Self { + picture: picture.clone(), latest, status_text, generation, running, + mirrored, selected_device: None, selected_mode: None, relay_preview_path: None, @@ -55,6 +62,14 @@ impl LocalCameraPreview { self.is_running() && self.relay_preview_path.is_some() } + fn set_mirrored(&mut self, mirrored: bool) { + self.mirrored.store(mirrored, Ordering::Release); + } + + fn mirrored(&self) -> bool { + self.mirrored.load(Ordering::Acquire) + } + fn set_selected(&mut self, camera: Option<&str>) -> Result<()> { self.selected_device = normalize_camera_selection(camera); @@ -186,6 +201,8 @@ impl LocalCameraPreview { if let Ok(mut latest) = self.latest.lock() { *latest = None; } + self.picture + .set_paintable(Some(&camera_preview_placeholder_texture())); let message = if was_relay_file { "Relay webcam preview stopped.".to_string() } else { @@ -212,17 +229,41 @@ impl LocalCameraPreview { } } -fn blank_camera_preview_texture() -> gdk::MemoryTexture { - let rgba = - vec![12_u8; (CAMERA_PREVIEW_DEFAULT_WIDTH * CAMERA_PREVIEW_DEFAULT_HEIGHT * 4) as usize]; - let bytes = glib::Bytes::from_owned(rgba); - gdk::MemoryTexture::new( - CAMERA_PREVIEW_DEFAULT_WIDTH, - CAMERA_PREVIEW_DEFAULT_HEIGHT, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - (CAMERA_PREVIEW_DEFAULT_WIDTH * 4) as usize, - ) +fn camera_preview_placeholder_texture() -> gdk::Texture { + let path = format!( + "{}/assets/placeholders/webcam_disabled.png", + env!("CARGO_MANIFEST_DIR") + ); + gdk::Texture::from_filename(path).unwrap_or_else(|_| { + let rgba = vec![ + 12_u8; + (CAMERA_PREVIEW_DEFAULT_WIDTH * CAMERA_PREVIEW_DEFAULT_HEIGHT * 4) as usize + ]; + let bytes = glib::Bytes::from_owned(rgba); + glib::object::Cast::upcast(gdk::MemoryTexture::new( + CAMERA_PREVIEW_DEFAULT_WIDTH, + CAMERA_PREVIEW_DEFAULT_HEIGHT, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + (CAMERA_PREVIEW_DEFAULT_WIDTH * 4) as usize, + )) + }) +} + +fn mirror_preview_frame(frame: &mut PreviewFrame) { + let width = frame.width.max(0) as usize; + if width == 0 { + return; + } + for row in frame.rgba.chunks_exact_mut(frame.stride) { + let row = &mut row[..width * 4]; + for left_pixel in 0..(width / 2) { + let right_pixel = width - 1 - left_pixel; + for channel in 0..4 { + row.swap(left_pixel * 4 + channel, right_pixel * 4 + channel); + } + } + } } impl LocalMicrophoneMonitor { diff --git a/client/src/launcher/tests/device_test.rs b/client/src/launcher/tests/device_test.rs index 872f1eb..0ea3f37 100644 --- a/client/src/launcher/tests/device_test.rs +++ b/client/src/launcher/tests/device_test.rs @@ -1,7 +1,8 @@ use super::{ - MIC_REPLAY_MAX_BYTES, build_wav_bytes, camera_preview_mode, camera_preview_pipeline_desc, - microphone_monitor_pipeline_desc, normalize_camera_selection, push_recent_audio, - read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device, + MIC_REPLAY_MAX_BYTES, PreviewFrame, build_wav_bytes, camera_preview_mode, + camera_preview_pipeline_desc, microphone_monitor_pipeline_desc, mirror_preview_frame, + normalize_camera_selection, push_recent_audio, read_camera_preview_tap, + read_microphone_level_tap, resolve_camera_device, }; use crate::launcher::devices::CameraMode; use std::sync::{Arc, Mutex}; @@ -114,3 +115,15 @@ fn relay_microphone_level_tap_clamps_values() { assert_eq!(read_microphone_level_tap(&path), None); let _ = std::fs::remove_file(path); } + +#[test] +fn mirror_preview_frame_flips_each_row_without_changing_channels() { + let mut frame = PreviewFrame { + width: 2, + height: 1, + stride: 8, + rgba: vec![1, 2, 3, 4, 9, 10, 11, 12], + }; + mirror_preview_frame(&mut frame); + assert_eq!(frame.rgba, vec![9, 10, 11, 12, 1, 2, 3, 4]); +} diff --git a/client/src/launcher/ui/local_test_bindings.rs b/client/src/launcher/ui/local_test_bindings.rs index 802b00a..dcce162 100644 --- a/client/src/launcher/ui/local_test_bindings.rs +++ b/client/src/launcher/ui/local_test_bindings.rs @@ -25,6 +25,29 @@ }); } + { + let widgets = widgets.clone(); + let tests = Rc::clone(&tests); + let camera_mirror_button = widgets.camera_mirror_button.clone(); + camera_mirror_button.connect_toggled(move |button| { + tests + .borrow_mut() + .set_camera_preview_mirrored(button.is_active()); + button.set_tooltip_text(Some(if button.is_active() { + "Launcher preview mirrored." + } else { + "Mirror launcher preview only." + })); + if tests.borrow_mut().is_running(DeviceTestKind::Camera) { + widgets.status_label.set_text(if button.is_active() { + "Launcher webcam preview mirrored. The actual uplink stays untouched." + } else { + "Launcher webcam preview returned to the real uplink orientation." + }); + } + }); + } + { let widgets = widgets.clone(); let tests = Rc::clone(&tests); diff --git a/client/src/launcher/ui/media_device_bindings.rs b/client/src/launcher/ui/media_device_bindings.rs index c2b9125..674112c 100644 --- a/client/src/launcher/ui/media_device_bindings.rs +++ b/client/src/launcher/ui/media_device_bindings.rs @@ -84,11 +84,18 @@ let state = Rc::clone(&state); let widgets = widgets.clone(); let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); let toggle = widgets.camera_channel_toggle.clone(); toggle.connect_toggled(move |toggle| { if let Ok(mut state) = state.try_borrow_mut() { state.set_camera_channel_enabled(toggle.is_active()); } + if !toggle.is_active() { + tests.borrow_mut().stop_camera_preview(); + widgets + .status_label + .set_text("Camera stream disabled. Webcam preview stopped."); + } if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { refresh_launcher_ui( &widgets, @@ -96,6 +103,7 @@ child_proc.borrow().is_some(), ); } + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); }); } @@ -103,11 +111,20 @@ let state = Rc::clone(&state); let widgets = widgets.clone(); let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); let toggle = widgets.microphone_channel_toggle.clone(); toggle.connect_toggled(move |toggle| { if let Ok(mut state) = state.try_borrow_mut() { state.set_microphone_channel_enabled(toggle.is_active()); } + if !toggle.is_active() { + let mut tests = tests.borrow_mut(); + tests.stop_microphone_monitor(); + tests.stop_microphone_replay(); + widgets + .status_label + .set_text("Mic stream disabled. Mic monitor and replay stopped."); + } if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { refresh_launcher_ui( &widgets, @@ -115,6 +132,7 @@ child_proc.borrow().is_some(), ); } + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); }); } @@ -122,11 +140,20 @@ let state = Rc::clone(&state); let widgets = widgets.clone(); let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); let toggle = widgets.audio_channel_toggle.clone(); toggle.connect_toggled(move |toggle| { if let Ok(mut state) = state.try_borrow_mut() { state.set_audio_channel_enabled(toggle.is_active()); } + if !toggle.is_active() { + let mut tests = tests.borrow_mut(); + tests.stop_speaker_test(); + tests.stop_microphone_replay(); + widgets + .status_label + .set_text("Speaker stream disabled. Local audio playback stopped."); + } if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { refresh_launcher_ui( &widgets, @@ -134,6 +161,7 @@ child_proc.borrow().is_some(), ); } + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); }); } } diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 3c0d38a..9ab9222 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -65,6 +65,8 @@ pub fn build_launcher_view( preview_panel, camera_preview_frame, camera_preview, + camera_mirror_button, + camera_mirror_revealer, camera_status, camera_test_button, microphone_test_button, diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs index 3afc7bf..30b1d30 100644 --- a/client/src/launcher/ui_components/assemble_view.rs +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -141,6 +141,8 @@ device_refresh_button: device_refresh_button.clone(), swap_key_button: swap_key_button.clone(), camera_test_button: camera_test_button.clone(), + camera_mirror_button: camera_mirror_button.clone(), + camera_mirror_revealer: camera_mirror_revealer.clone(), microphone_test_button: microphone_test_button.clone(), microphone_replay_button: microphone_replay_button.clone(), speaker_test_button: speaker_test_button.clone(), @@ -173,6 +175,7 @@ preview_panel, camera_preview_frame, camera_preview, + camera_mirror_button, camera_status, }, widgets, diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs index 3d86d1f..1949440 100644 --- a/client/src/launcher/ui_components/build_contexts.rs +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -35,6 +35,8 @@ struct DeviceControlsContext { preview_panel: gtk::Box, camera_preview_frame: gtk::AspectFrame, camera_preview: gtk::Picture, + camera_mirror_button: gtk::ToggleButton, + camera_mirror_revealer: gtk::Revealer, camera_status: gtk::Label, camera_test_button: gtk::Button, microphone_test_button: gtk::Button, diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index f5112cb..b845777 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -219,6 +219,25 @@ ); camera_preview.set_keep_aspect_ratio(true); camera_preview.add_css_class("camera-preview-frame"); + let camera_mirror_button = gtk::ToggleButton::new(); + camera_mirror_button.add_css_class("camera-preview-mirror-toggle"); + camera_mirror_button.add_css_class("flat"); + camera_mirror_button.set_focus_on_click(false); + camera_mirror_button.set_halign(gtk::Align::End); + camera_mirror_button.set_valign(gtk::Align::Start); + camera_mirror_button.set_margin_top(10); + camera_mirror_button.set_margin_end(10); + camera_mirror_button.set_tooltip_text(Some("Mirror launcher preview only.")); + camera_mirror_button.set_visible(false); + let camera_mirror_icon = gtk::Image::from_icon_name("object-flip-horizontal-symbolic"); + camera_mirror_button.set_child(Some(&camera_mirror_icon)); + let camera_mirror_revealer = gtk::Revealer::new(); + camera_mirror_revealer.set_transition_type(gtk::RevealerTransitionType::Crossfade); + camera_mirror_revealer.set_transition_duration(120); + camera_mirror_revealer.set_halign(gtk::Align::End); + camera_mirror_revealer.set_valign(gtk::Align::Start); + camera_mirror_revealer.set_reveal_child(false); + camera_mirror_revealer.set_child(Some(&camera_mirror_button)); let camera_status = gtk::Label::new(Some("Select a webcam and click Start Preview.")); camera_status.add_css_class("dim-label"); camera_status.set_wrap(false); @@ -244,7 +263,34 @@ CAMERA_PREVIEW_VIEWPORT_HEIGHT, ); camera_preview_frame.set_child(Some(&camera_preview)); - camera_preview_shell.append(&camera_preview_frame); + let camera_preview_overlay = gtk::Overlay::new(); + camera_preview_overlay.set_hexpand(true); + camera_preview_overlay.set_vexpand(true); + camera_preview_overlay.set_halign(gtk::Align::Fill); + camera_preview_overlay.set_valign(gtk::Align::Fill); + camera_preview_overlay.set_size_request( + CAMERA_PREVIEW_VIEWPORT_WIDTH, + CAMERA_PREVIEW_VIEWPORT_HEIGHT, + ); + camera_preview_overlay.set_child(Some(&camera_preview_frame)); + camera_preview_overlay.add_overlay(&camera_mirror_revealer); + let hover_revealer = camera_mirror_revealer.clone(); + let hover_button = camera_mirror_button.clone(); + let hover_controller = gtk::EventControllerMotion::new(); + hover_controller.connect_enter(move |_, _, _| { + if hover_button.is_visible() { + hover_revealer.set_reveal_child(true); + } + }); + let leave_revealer = camera_mirror_revealer.clone(); + let leave_button = camera_mirror_button.clone(); + hover_controller.connect_leave(move |_| { + if leave_button.is_visible() { + leave_revealer.set_reveal_child(false); + } + }); + camera_preview_overlay.add_controller(hover_controller); + camera_preview_shell.append(&camera_preview_overlay); let webcam_group = build_subgroup("Webcam Preview"); webcam_group.set_hexpand(true); webcam_group.set_vexpand(true); @@ -299,6 +345,8 @@ preview_panel, camera_preview_frame, camera_preview, + camera_mirror_button, + camera_mirror_revealer, camera_status, camera_test_button, microphone_test_button, diff --git a/client/src/launcher/ui_components/style.rs b/client/src/launcher/ui_components/style.rs index c2af3d3..684cdd9 100644 --- a/client/src/launcher/ui_components/style.rs +++ b/client/src/launcher/ui_components/style.rs @@ -100,6 +100,23 @@ pub fn install_css(window: >k::ApplicationWindow) { border: 1px solid rgba(255, 255, 255, 0.10); border-radius: 14px; } + button.camera-preview-mirror-toggle { + min-width: 36px; + min-height: 36px; + padding: 0; + border-radius: 999px; + background: rgba(16, 19, 25, 0.28); + border: 1px solid rgba(255, 255, 255, 0.14); + color: rgba(238, 242, 247, 0.92); + } + button.camera-preview-mirror-toggle:checked { + background: rgba(76, 154, 255, 0.26); + border-color: rgba(76, 154, 255, 0.58); + color: #eef6ff; + } + button.camera-preview-mirror-toggle image { + -gtk-icon-size: 18px; + } label.status-line { opacity: 0.9; } diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index 15de3cc..b5bbb12 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -145,6 +145,8 @@ pub struct LauncherWidgets { pub device_refresh_button: gtk::Button, pub swap_key_button: gtk::Button, pub camera_test_button: gtk::Button, + pub camera_mirror_button: gtk::ToggleButton, + pub camera_mirror_revealer: gtk::Revealer, pub microphone_test_button: gtk::Button, pub microphone_replay_button: gtk::Button, pub speaker_test_button: gtk::Button, @@ -164,6 +166,7 @@ pub struct DeviceStageWidgets { pub preview_panel: gtk::Box, pub camera_preview_frame: gtk::AspectFrame, pub camera_preview: gtk::Picture, + pub camera_mirror_button: gtk::ToggleButton, pub camera_status: gtk::Label, } diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index e111019..e3ea660 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -211,6 +211,15 @@ pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestCon } else { "Start Preview" }); + let camera_mirrored = tests.camera_preview_mirrored(); + if widgets.camera_mirror_button.is_active() != camera_mirrored { + widgets.camera_mirror_button.set_active(camera_mirrored); + } + widgets.camera_mirror_button.set_visible(camera_running); + widgets.camera_mirror_button.set_sensitive(camera_running); + if !camera_running { + widgets.camera_mirror_revealer.set_reveal_child(false); + } widgets .microphone_test_button .set_label(if microphone_running {