diff --git a/client/src/launcher/device_test/local_preview.rs b/client/src/launcher/device_test/local_preview.rs index 45ebfb7..dc19a46 100644 --- a/client/src/launcher/device_test/local_preview.rs +++ b/client/src/launcher/device_test/local_preview.rs @@ -229,7 +229,7 @@ impl LocalCameraPreview { } } -fn camera_preview_placeholder_texture() -> gdk::Texture { +pub(super) fn camera_preview_placeholder_texture() -> gdk::Texture { let path = format!( "{}/assets/placeholders/webcam_disabled.png", env!("CARGO_MANIFEST_DIR") diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 9f3723a..56a3f96 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -186,6 +186,18 @@ fn populated_launcher_runtime_widgets_stay_compact() { view.device_stage.devices_panel.height(), view.device_stage.preview_panel.height() ); + assert_eq!( + view.device_stage + .camera_preview_stack + .visible_child_name() + .as_deref(), + Some("idle"), + "idle launcher should show the placeholder webcam surface until preview wakes up" + ); + view.device_stage + .camera_preview_stack + .set_visible_child_name("live"); + present_and_settle(&view.window); assert!( view.widgets.display_panes[0].preview_frame.height() >= view.device_stage.camera_preview_frame.height(), diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 9ab9222..1e5f189 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -63,6 +63,7 @@ pub fn build_launcher_view( audio_check_meter, devices_panel, preview_panel, + camera_preview_stack, camera_preview_frame, camera_preview, camera_mirror_button, diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs index 30b1d30..201ad98 100644 --- a/client/src/launcher/ui_components/assemble_view.rs +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -141,6 +141,7 @@ device_refresh_button: device_refresh_button.clone(), swap_key_button: swap_key_button.clone(), camera_test_button: camera_test_button.clone(), + camera_preview_stack: camera_preview_stack.clone(), camera_mirror_button: camera_mirror_button.clone(), camera_mirror_revealer: camera_mirror_revealer.clone(), microphone_test_button: microphone_test_button.clone(), @@ -173,6 +174,7 @@ device_stage: DeviceStageWidgets { devices_panel, preview_panel, + camera_preview_stack, camera_preview_frame, camera_preview, camera_mirror_button, diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs index 1949440..3f61642 100644 --- a/client/src/launcher/ui_components/build_contexts.rs +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -33,6 +33,7 @@ struct DeviceControlsContext { audio_check_meter: gtk::ProgressBar, devices_panel: gtk::Box, preview_panel: gtk::Box, + camera_preview_stack: gtk::Stack, camera_preview_frame: gtk::AspectFrame, camera_preview: gtk::Picture, camera_mirror_button: gtk::ToggleButton, diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index b845777..c564be3 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -219,6 +219,15 @@ ); camera_preview.set_keep_aspect_ratio(true); camera_preview.add_css_class("camera-preview-frame"); + let camera_preview_idle = gtk::Picture::new(); + camera_preview_idle.set_can_shrink(true); + camera_preview_idle.set_hexpand(true); + camera_preview_idle.set_vexpand(true); + camera_preview_idle.set_halign(gtk::Align::Fill); + camera_preview_idle.set_valign(gtk::Align::Fill); + camera_preview_idle.set_keep_aspect_ratio(true); + camera_preview_idle.add_css_class("display-placeholder-art"); + camera_preview_idle.set_paintable(Some(&super::device_test::camera_preview_placeholder_texture())); let camera_mirror_button = gtk::ToggleButton::new(); camera_mirror_button.add_css_class("camera-preview-mirror-toggle"); camera_mirror_button.add_css_class("flat"); @@ -290,7 +299,35 @@ } }); camera_preview_overlay.add_controller(hover_controller); - camera_preview_shell.append(&camera_preview_overlay); + let camera_preview_idle_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); + camera_preview_idle_frame.set_hexpand(true); + camera_preview_idle_frame.set_vexpand(false); + camera_preview_idle_frame.set_halign(gtk::Align::Fill); + camera_preview_idle_frame.set_valign(gtk::Align::End); + camera_preview_idle_frame.set_size_request( + CAMERA_PREVIEW_VIEWPORT_WIDTH, + CAMERA_PREVIEW_VIEWPORT_HEIGHT, + ); + camera_preview_idle_frame.set_child(Some(&camera_preview_idle)); + let camera_preview_idle_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); + camera_preview_idle_shell.set_hexpand(true); + camera_preview_idle_shell.set_vexpand(true); + camera_preview_idle_shell.set_halign(gtk::Align::Fill); + camera_preview_idle_shell.set_valign(gtk::Align::Fill); + camera_preview_idle_shell.append(&camera_preview_idle_frame); + let camera_preview_stack = gtk::Stack::new(); + camera_preview_stack.set_hexpand(true); + camera_preview_stack.set_vexpand(true); + camera_preview_stack.set_halign(gtk::Align::Fill); + camera_preview_stack.set_valign(gtk::Align::Fill); + camera_preview_stack.set_size_request( + CAMERA_PREVIEW_VIEWPORT_WIDTH, + CAMERA_PREVIEW_VIEWPORT_HEIGHT, + ); + camera_preview_stack.add_named(&camera_preview_idle_shell, Some("idle")); + camera_preview_stack.add_named(&camera_preview_overlay, Some("live")); + camera_preview_stack.set_visible_child_name("idle"); + camera_preview_shell.append(&camera_preview_stack); let webcam_group = build_subgroup("Webcam Preview"); webcam_group.set_hexpand(true); webcam_group.set_vexpand(true); @@ -343,6 +380,7 @@ audio_check_meter, devices_panel, preview_panel, + camera_preview_stack, camera_preview_frame, camera_preview, camera_mirror_button, diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index b5bbb12..a9cae17 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -145,6 +145,7 @@ pub struct LauncherWidgets { pub device_refresh_button: gtk::Button, pub swap_key_button: gtk::Button, pub camera_test_button: gtk::Button, + pub camera_preview_stack: gtk::Stack, pub camera_mirror_button: gtk::ToggleButton, pub camera_mirror_revealer: gtk::Revealer, pub microphone_test_button: gtk::Button, @@ -164,6 +165,7 @@ pub struct LauncherWidgets { pub struct DeviceStageWidgets { pub devices_panel: gtk::Box, pub preview_panel: gtk::Box, + pub camera_preview_stack: gtk::Stack, pub camera_preview_frame: gtk::AspectFrame, pub camera_preview: gtk::Picture, pub camera_mirror_button: gtk::ToggleButton, diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index e3ea660..159c242 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -211,6 +211,9 @@ pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestCon } else { "Start Preview" }); + widgets + .camera_preview_stack + .set_visible_child_name(if camera_running { "live" } else { "idle" }); let camera_mirrored = tests.camera_preview_mirrored(); if widgets.camera_mirror_button.is_active() != camera_mirrored { widgets.camera_mirror_button.set_active(camera_mirrored); diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index c068056..ef6cce9 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -170,6 +170,19 @@ fn device_testing_keeps_webcam_and_mic_playback_compact() { assert!(UI_LAYOUT_SRC.contains("webcam_group.set_valign(gtk::Align::Fill);")); assert!(UI_LAYOUT_SRC.contains("camera_preview.set_can_shrink(true);")); assert!(UI_LAYOUT_SRC.contains("camera_preview.set_vexpand(true);")); + assert!( + UI_LAYOUT_SRC.contains("camera_preview_idle.add_css_class(\"display-placeholder-art\");") + ); + assert!( + UI_LAYOUT_SRC.contains( + "camera_preview_stack.add_named(&camera_preview_idle_shell, Some(\"idle\"));" + ) + ); + assert!( + UI_LAYOUT_SRC + .contains("camera_preview_stack.add_named(&camera_preview_overlay, Some(\"live\"));") + ); + assert!(UI_LAYOUT_SRC.contains("camera_preview_stack.set_visible_child_name(\"idle\");")); assert!(UI_LAYOUT_SRC.contains("camera_preview_frame.set_vexpand(false);")); assert!(UI_LAYOUT_SRC.contains("camera_preview_frame.set_valign(gtk::Align::End);")); assert!(UI_LAYOUT_SRC.contains("playback_group.set_valign(gtk::Align::Fill);"));