ui(launcher): mirror upstream preview and stop disabled media tests

This commit is contained in:
Brad Stein 2026-04-23 19:11:54 -03:00
parent a305746a0e
commit 6d0387ace7
15 changed files with 247 additions and 17 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 KiB

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

View File

@ -74,10 +74,15 @@ impl DeviceTestController {
camera_picture: &gtk::Picture,
camera_status: &gtk::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<bool> {
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<bool> {
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<Mutex<Option<PreviewFrame>>>,
status_text: Arc<Mutex<String>>,
generation: Arc<AtomicU64>,
running: Arc<AtomicBool>,
mirrored: Arc<AtomicBool>,
selected_device: Option<String>,
selected_mode: Option<CameraMode>,
relay_preview_path: Option<PathBuf>,

View File

@ -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 {

View File

@ -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]);
}

View File

@ -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);

View File

@ -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());
});
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -100,6 +100,23 @@ pub fn install_css(window: &gtk::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;
}

View File

@ -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,
}

View File

@ -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 {