Skip to main content

app_ui/
camera_picker.rs

1//! `<CameraPicker />` — live-data dropdown selector for the Recorder
2//! surface (M-CAM.4 / AUT-258 + M-REC.1 / AUT-260).
3//!
4//! Combines the M-CAM.2 IPC helpers with the storybook
5//! `CaptureSourceRow` + `DevicePickerMenu` shapes (M-UI.8 / AUT-128)
6//! to provide:
7//!
8//! - On mount: invoke `list_cameras` once to seed the picker.
9//! - On open: re-fetch so newly-plugged USB cameras appear.
10//! - On select: invoke `start_preview(camera_id)` + persist the
11//!   selection to `LocalStorage` so re-opens land on the same camera.
12//! - Permission-denied state mirrors M-CAM.3's `RecorderPreviewState`
13//!   so the picker UI matches the canvas overlay.
14//!
15//! ```admonish note title="Storybook component reuse"
16//! The visual elements (`CaptureSourceRow`, `DevicePickerMenu`)
17//! live in `ui-storybook` and are pure presentational — they take
18//! view-models, not state. This module wires the storybook
19//! components to live IPC data via Leptos `Resource` + signals.
20//! ```
21
22use leptos::prelude::*;
23use leptos::task::spawn_local;
24use ui_storybook::components::primitives::ChevronDown;
25
26use crate::camera_ipc::{self, CameraPermission, CameraView};
27
28/// `LocalStorage` key for the "last used" camera id. Only referenced
29/// from the wasm32 `read_last_used` / `write_last_used` helpers, so
30/// cfg-gated to match — otherwise the native-target build (which CI's
31/// `just lint` exercises) trips the `dead_code` lint.
32#[cfg(target_arch = "wasm32")]
33const LAST_USED_KEY: &str = "screen.camera.last_used_id";
34
35/// `<CameraPicker />` — live device picker rendered above the
36/// `<CameraPreview />` canvas in the Recorder surface.
37#[component]
38pub fn CameraPicker() -> impl IntoView {
39    let cameras = RwSignal::new(Vec::<CameraView>::new());
40    let selected_id = RwSignal::new(Option::<String>::None);
41    let permission = RwSignal::new(CameraPermission::Granted);
42    let open = RwSignal::new(false);
43    // M-RECORD.3 — disable the trigger while a coordinated
44    // recording session is active so the user can't swap cameras
45    // mid-record (would invalidate the M-EXPORT encoder state).
46    let recording_lock = RwSignal::new(false);
47    crate::recording_ipc::install_recording_lock_listener(recording_lock);
48
49    refresh_cameras(cameras, selected_id, permission, true);
50
51    let on_select = move |id: String| {
52        // Persist + propagate.
53        write_last_used(&id);
54        let id_for_invoke = id.clone();
55        spawn_local(async move {
56            camera_ipc::start_preview(id_for_invoke).await;
57        });
58        selected_id.set(Some(id));
59        open.set(false);
60    };
61
62    view! {
63        <div class="camera-picker">
64            <button
65                class="camera-picker-trigger"
66                aria-haspopup="listbox"
67                aria-expanded=move || open.get()
68                prop:disabled=move || recording_lock.get()
69                title=move || if recording_lock.get() { "Recording in progress — stop the recording to change cameras" } else { "" }
70                on:click=move |_| {
71                    if recording_lock.get() { return; }
72                    let will_open = !open.get_untracked();
73                    open.set(will_open);
74                    if will_open {
75                        refresh_cameras(cameras, selected_id, permission, false);
76                    }
77                }
78            >
79                <span class="camera-picker-label">
80                    {move || selected_label(&cameras.get(), selected_id.get().as_ref())}
81                </span>
82                <span class="camera-picker-chevron" aria-hidden="true">
83                    <ChevronDown />
84                </span>
85            </button>
86            <Show when=move || open.get() fallback=|| view! { <></> }>
87                <div class="camera-picker-menu" role="listbox">
88                    <CameraPickerBody
89                        cameras=cameras
90                        selected_id=selected_id
91                        permission=permission
92                        on_select=Callback::new(on_select)
93                    />
94                </div>
95            </Show>
96        </div>
97    }
98}
99
100#[component]
101fn CameraPickerBody(
102    cameras: RwSignal<Vec<CameraView>>,
103    selected_id: RwSignal<Option<String>>,
104    permission: RwSignal<CameraPermission>,
105    on_select: Callback<String>,
106) -> impl IntoView {
107    move || {
108        match (permission.get(), cameras.get()) {
109        (CameraPermission::Denied | CameraPermission::NotDetermined, _) => view! {
110            <div class="camera-picker-state camera-picker-state--permission">
111                <p>{"Camera access required."}</p>
112                <p class="camera-picker-state-help">
113                    {"Grant access in System Settings → Privacy & Security, then re-open this picker."}
114                </p>
115                <button
116                    type="button"
117                    class="camera-picker-state-button"
118                    on:click=move |_| {
119                        spawn_local(async move {
120                            camera_ipc::open_settings_camera().await;
121                        });
122                    }
123                >
124                    {"Open System Settings → Camera"}
125                </button>
126            </div>
127        }
128        .into_any(),
129        (CameraPermission::Granted, list) if list.is_empty() => view! {
130            <div class="camera-picker-state camera-picker-state--empty">
131                <p>{"No cameras detected."}</p>
132                <p class="camera-picker-state-help">
133                    {"On first launch the macOS permission prompts may not have fired yet. Click below to grant access to camera + microphone + screen recording in one go, then re-open this picker."}
134                </p>
135                <button
136                    type="button"
137                    class="camera-picker-state-button"
138                    on:click=move |_| {
139                        spawn_local(async move {
140                            let result = camera_ipc::request_all_permissions().await;
141                            log_to_console(&format!(
142                                "request_all_permissions returned: camera={:?}, mic={:?}, screen={:?}",
143                                result.camera, result.microphone, result.screen_recording
144                            ));
145                            // Re-enumerate after the user grants access.
146                            permission.set(result.camera);
147                            let list = camera_ipc::list_cameras().await;
148                            cameras.set(list);
149                        });
150                    }
151                >
152                    {"Grant access (camera, mic, screen recording)"}
153                </button>
154                <p class="camera-picker-state-help">
155                    {"If the prompts don't appear: System Settings → Privacy & Security → enable Screen under Camera, Microphone, and Screen Recording."}
156                </p>
157            </div>
158        }
159        .into_any(),
160        (CameraPermission::Granted, list) => view! {
161            <ul class="camera-picker-list" role="none">
162                {list
163                    .into_iter()
164                    .map(|cam| render_row(cam, selected_id.get(), on_select))
165                    .collect_view()}
166            </ul>
167        }
168        .into_any(),
169    }
170    }
171}
172
173fn refresh_cameras(
174    cameras: RwSignal<Vec<CameraView>>,
175    selected_id: RwSignal<Option<String>>,
176    permission: RwSignal<CameraPermission>,
177    autostart_preview: bool,
178) {
179    spawn_local(async move {
180        let perm = camera_ipc::camera_permission_status().await;
181        permission.set(perm);
182        let list = camera_ipc::list_cameras().await;
183        let default_id = selected_id
184            .get_untracked()
185            .filter(|id| list.iter().any(|cam| cam.id == *id))
186            .or_else(|| resolve_default(&list));
187        if autostart_preview && let Some(id) = &default_id {
188            let id = id.clone();
189            spawn_local(async move {
190                camera_ipc::start_preview(id).await;
191            });
192        }
193        selected_id.set(default_id);
194        cameras.set(list);
195    });
196}
197
198fn render_row(
199    cam: CameraView,
200    selected: Option<String>,
201    on_select: Callback<String>,
202) -> impl IntoView {
203    let is_selected = selected.as_deref() == Some(cam.id.as_str());
204    let mut class = String::from("camera-picker-row");
205    if is_selected {
206        class.push_str(" camera-picker-row-selected");
207    }
208    let id_for_click = cam.id.clone();
209    let id_aria = cam.id.clone();
210    let label = cam.label.clone();
211    view! {
212        <li>
213            <button
214                class=class
215                role="option"
216                aria-selected=is_selected
217                data-camera-id=id_aria
218                on:click=move |_| on_select.run(id_for_click.clone())
219            >
220                <span class="camera-picker-row-label">{label}</span>
221                {is_selected.then(|| view! {
222                    <span class="camera-picker-row-check" aria-hidden="true">"✓"</span>
223                })}
224            </button>
225        </li>
226    }
227}
228
229fn selected_label(cameras: &[CameraView], selected_id: Option<&String>) -> String {
230    let Some(id) = selected_id else {
231        return "Select camera".into();
232    };
233    cameras
234        .iter()
235        .find(|c| c.id == *id)
236        .map_or_else(|| "Select camera".to_string(), |c| c.label.clone())
237}
238
239/// Resolve which camera should be selected on initial mount:
240/// 1. Persisted "last used" id (if present + still in the device list).
241/// 2. The `is_default = true` device.
242/// 3. The first device.
243/// 4. `None` if no devices.
244fn resolve_default(cameras: &[CameraView]) -> Option<String> {
245    if cameras.is_empty() {
246        return None;
247    }
248    if let Some(last) = read_last_used()
249        && cameras.iter().any(|c| c.id == last)
250    {
251        return Some(last);
252    }
253    cameras
254        .iter()
255        .find(|c| c.is_default)
256        .map(|c| c.id.clone())
257        .or_else(|| cameras.first().map(|c| c.id.clone()))
258}
259
260#[cfg(target_arch = "wasm32")]
261fn read_last_used() -> Option<String> {
262    let storage = web_sys::window()?.local_storage().ok().flatten()?;
263    storage.get_item(LAST_USED_KEY).ok().flatten()
264}
265
266#[cfg(not(target_arch = "wasm32"))]
267fn read_last_used() -> Option<String> {
268    // Native unit-test path: web_sys::window() doesn't exist in
269    // native nextest. Returning None lets the resolve_default tests
270    // exercise the fallback branches without touching `LocalStorage`.
271    None
272}
273
274#[cfg(target_arch = "wasm32")]
275fn write_last_used(id: &str) {
276    let Some(window) = web_sys::window() else {
277        return;
278    };
279    let Ok(Some(storage)) = window.local_storage() else {
280        return;
281    };
282    let _ = storage.set_item(LAST_USED_KEY, id);
283}
284
285#[cfg(not(target_arch = "wasm32"))]
286fn write_last_used(_id: &str) {
287    // Native: no-op. LocalStorage is only meaningful in the browser
288    // CSR target.
289}
290
291/// Best-effort `console.log(msg)` so the user sees the
292/// permission-request result in the webview's devtools console.
293#[cfg(target_arch = "wasm32")]
294fn log_to_console(msg: &str) {
295    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(msg));
296}
297
298#[cfg(not(target_arch = "wasm32"))]
299fn log_to_console(_msg: &str) {}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn cam(id: &str, label: &str, is_default: bool) -> CameraView {
306        CameraView {
307            id: id.into(),
308            label: label.into(),
309            is_default,
310        }
311    }
312
313    #[test]
314    fn resolve_default_returns_none_for_empty() {
315        assert_eq!(resolve_default(&[]), None);
316    }
317
318    #[test]
319    fn resolve_default_picks_is_default_when_no_persisted() {
320        let list = vec![
321            cam("a", "Cam A", false),
322            cam("b", "Cam B", true),
323            cam("c", "Cam C", false),
324        ];
325        // Note: in a unit-test environment LocalStorage doesn't
326        // exist, so read_last_used returns None and we fall through
327        // to the is_default branch.
328        assert_eq!(resolve_default(&list), Some("b".into()));
329    }
330
331    #[test]
332    fn resolve_default_falls_back_to_first_when_none_default() {
333        let list = vec![cam("a", "Cam A", false), cam("b", "Cam B", false)];
334        assert_eq!(resolve_default(&list), Some("a".into()));
335    }
336
337    #[test]
338    fn selected_label_returns_placeholder_when_unselected() {
339        assert_eq!(selected_label(&[], None), "Select camera");
340    }
341
342    #[test]
343    fn selected_label_returns_camera_label_when_selected() {
344        let list = vec![cam("a", "FaceTime HD", true)];
345        let id = "a".to_string();
346        assert_eq!(selected_label(&list, Some(&id)), "FaceTime HD");
347    }
348}