Skip to main content

app_ui/
screen_picker.rs

1//! `<ScreenPicker />` — screen / window source picker for the
2//! Recorder surface (M-SCK.4 / AUT-271 + M-SCK.0.1 / AUT-291).
3//!
4//! Mirror of [`crate::system_audio_picker`] for the visual capture
5//! path. Master Start/Stop toggle drives the SCK screen-capture
6//! session; the expandable dropdown shows attached displays + visible
7//! windows. **As of M-SCK.0.1 (M-RECORD-EXPORT)**, clicking a non-
8//! primary row swaps the active `SCStream` to that source — picker
9//! state is the authoritative selection and persists across launches
10//! via `LocalStorage`.
11//!
12//! ```admonish important title="Last-used recovery"
13//! Window IDs are NOT stable across launches (Apple's `CGWindowID`
14//! is per-session). The picker reads `screen.screen_capture.last_source_id`
15//! on mount; if the persisted id doesn't match any current display
16//! or window (`list_screen_displays` + `list_screen_windows`), the
17//! picker silently falls back to `PrimaryDisplay`. Documented gotcha
18//! class in M-SCK.1 (AUT-268).
19//! ```
20
21use leptos::prelude::*;
22use leptos::task::spawn_local;
23use ui_storybook::components::primitives::ChevronDown;
24
25use crate::screen_ipc::{self, DisplaySourceView, ScreenSourcesResult, WindowSourceView};
26
27/// `LocalStorage` key for the persisted picker selection. Value is
28/// `"display-<id>"` / `"window-<id>"` for a pinned source; absent
29/// (or empty) means the picker falls back to `PrimaryDisplay` on
30/// next launch. cfg-gated to wasm32 because `read_last_used` /
31/// `write_last_used` are themselves wasm-only (native unit tests
32/// don't touch `LocalStorage`); leaving it on native trips the
33/// workspace `-D warnings` gate via `dead_code` (a recurring CI
34/// failure mode, last seen in PR #49's CSS-debounce const).
35#[cfg(target_arch = "wasm32")]
36const LAST_USED_KEY: &str = "screen.screen_capture.last_source_id";
37
38/// `<ScreenPicker />` — master toggle + expandable display/window list
39/// + per-row click-to-route (M-SCK.0.1 / AUT-291).
40#[component]
41pub fn ScreenPicker() -> impl IntoView {
42    let enabled = RwSignal::new(false);
43    let expanded = RwSignal::new(false);
44    let displays = RwSignal::new(Vec::<DisplaySourceView>::new());
45    let windows = RwSignal::new(Vec::<WindowSourceView>::new());
46    let error_message = RwSignal::new(Option::<String>::None);
47    // None = PrimaryDisplay (M-SCK.0 default). Set from LocalStorage
48    // on mount; replaced on row-click; cleared when the user toggles
49    // off + back on without picking a row (defaults to primary).
50    let active_source_id = RwSignal::new(read_last_used());
51    // M-RECORD.3 — lock master + expand while a coordinated session
52    // is active. Picker shows current state but disallows mutation.
53    let recording_lock = RwSignal::new(false);
54    crate::recording_ipc::install_recording_lock_listener(recording_lock);
55
56    // Probe live status on mount so the picker reflects an
57    // already-running session correctly.
58    spawn_local(async move {
59        let active = screen_ipc::screen_capture_status().await;
60        enabled.set(active);
61    });
62
63    let on_toggle_enabled = make_on_toggle_enabled(enabled, active_source_id, error_message);
64    let on_toggle_expand =
65        make_on_toggle_expand(expanded, displays, windows, error_message, active_source_id);
66    let on_pick_source = make_on_pick(enabled, active_source_id, error_message);
67
68    view! {
69        <div class="screen-picker">
70            <div class="screen-picker-header">
71                <button
72                    type="button"
73                    class="screen-picker-toggle"
74                    role="switch"
75                    aria-checked=move || enabled.get()
76                    data-enabled=move || if enabled.get() { "true" } else { "false" }
77                    prop:disabled=move || recording_lock.get()
78                    title=move || if recording_lock.get() { "Recording in progress — stop the recording to toggle screen capture" } else { "" }
79                    on:click=move |evt| {
80                        if recording_lock.get() { return; }
81                        on_toggle_enabled(evt);
82                    }
83                >
84                    <span class="screen-picker-icon" aria-hidden="true">"🖥"</span>
85                    <span class="screen-picker-label">"Screen"</span>
86                    <span class="screen-picker-state">
87                        {move || if enabled.get() { "On" } else { "Off" }}
88                    </span>
89                </button>
90                <button
91                    type="button"
92                    class="screen-picker-expand"
93                    aria-haspopup="listbox"
94                    aria-expanded=move || expanded.get()
95                    on:click=on_toggle_expand
96                >
97                    <span class="screen-picker-summary">"Sources"</span>
98                    <span class="screen-picker-chevron" aria-hidden="true">
99                        <ChevronDown />
100                    </span>
101                </button>
102            </div>
103            <Show when=move || expanded.get() fallback=|| view! { <></> }>
104                <div class="screen-picker-menu" role="listbox">
105                    <ScreenPickerBody
106                        displays=displays
107                        windows=windows
108                        error_message=error_message
109                        active_source_id=active_source_id
110                        on_pick=on_pick_source
111                    />
112                </div>
113            </Show>
114        </div>
115    }
116}
117
118/// Closure factory for the master toggle. Extracted so
119/// `ScreenPicker` stays under the `clippy::too_many_lines` cap.
120fn make_on_toggle_enabled(
121    enabled: RwSignal<bool>,
122    active_source_id: RwSignal<Option<String>>,
123    error_message: RwSignal<Option<String>>,
124) -> impl Fn(leptos::ev::MouseEvent) + Copy + Send + Sync + 'static {
125    move |_| {
126        let next = !enabled.get();
127        enabled.set(next);
128        let source_for_start = active_source_id.get();
129        spawn_local(async move {
130            if next {
131                match screen_ipc::start_screen_capture(source_for_start).await {
132                    Ok(()) => error_message.set(None),
133                    Err(err) => {
134                        enabled.set(false);
135                        error_message.set(Some(err));
136                    }
137                }
138            } else {
139                screen_ipc::stop_screen_capture().await;
140            }
141        });
142    }
143}
144
145/// Closure factory for the expand-dropdown button.
146fn make_on_toggle_expand(
147    expanded: RwSignal<bool>,
148    displays: RwSignal<Vec<DisplaySourceView>>,
149    windows: RwSignal<Vec<WindowSourceView>>,
150    error_message: RwSignal<Option<String>>,
151    active_source_id: RwSignal<Option<String>>,
152) -> impl Fn(leptos::ev::MouseEvent) + Copy + Send + Sync + 'static {
153    move |_| {
154        let next = !expanded.get();
155        expanded.set(next);
156        if !next {
157            return;
158        }
159        spawn_local(async move {
160            match screen_ipc::list_screen_displays().await {
161                ScreenSourcesResult::Ok(list) => {
162                    error_message.set(None);
163                    displays.set(list);
164                }
165                ScreenSourcesResult::Err(msg) => {
166                    error_message.set(Some(msg));
167                    displays.set(Vec::new());
168                }
169            }
170            match screen_ipc::list_screen_windows().await {
171                ScreenSourcesResult::Ok(list) => windows.set(list),
172                ScreenSourcesResult::Err(_) => windows.set(Vec::new()),
173            }
174            // After enumeration: stale persisted id → drop to
175            // primary. "I picked Safari window last week" case.
176            let displays_now = displays.get();
177            let windows_now = windows.get();
178            if let Some(id) = active_source_id.get()
179                && !displays_now.iter().any(|d| d.id == id)
180                && !windows_now.iter().any(|w| w.id == id)
181            {
182                active_source_id.set(None);
183                write_last_used(None);
184            }
185        });
186    }
187}
188
189/// Closure factory for per-row click. Persists the new source and,
190/// if a session is live, tears down + restarts on the new source.
191fn make_on_pick(
192    enabled: RwSignal<bool>,
193    active_source_id: RwSignal<Option<String>>,
194    error_message: RwSignal<Option<String>>,
195) -> impl Fn(Option<String>) + Copy + Send + Sync + 'static {
196    move |source_id: Option<String>| {
197        active_source_id.set(source_id.clone());
198        write_last_used(source_id.as_deref());
199        if enabled.get() {
200            let source_clone = source_id.clone();
201            spawn_local(async move {
202                screen_ipc::stop_screen_capture().await;
203                match screen_ipc::start_screen_capture(source_clone).await {
204                    Ok(()) => error_message.set(None),
205                    Err(err) => {
206                        enabled.set(false);
207                        error_message.set(Some(err));
208                    }
209                }
210            });
211        }
212    }
213}
214
215#[component]
216fn ScreenPickerBody(
217    displays: RwSignal<Vec<DisplaySourceView>>,
218    windows: RwSignal<Vec<WindowSourceView>>,
219    error_message: RwSignal<Option<String>>,
220    active_source_id: RwSignal<Option<String>>,
221    on_pick: impl Fn(Option<String>) + Copy + Send + Sync + 'static,
222) -> impl IntoView {
223    move || {
224        match (error_message.get(), displays.get(), windows.get()) {
225            (Some(msg), _, _) => view! {
226                <div class="screen-picker-state-msg screen-picker-state-msg--error">
227                    <p>{"Couldn't list screen sources."}</p>
228                    <p class="screen-picker-state-help">{msg}</p>
229                    <p class="screen-picker-state-help">
230                        {"Request access first; macOS will then add this app to System Settings. After enabling it, quit and reopen the app."}
231                    </p>
232                    <button
233                        type="button"
234                        class="screen-picker-state-button"
235                        on:click=move |_| {
236                            spawn_local(async move {
237                                screen_ipc::request_screen_recording_permission().await;
238                                screen_ipc::open_settings_screen_recording().await;
239                            });
240                        }
241                    >
242                        {"Request Screen Recording Access"}
243                    </button>
244                </div>
245            }
246            .into_any(),
247            (None, d_list, w_list) if d_list.is_empty() && w_list.is_empty() => view! {
248                <div class="screen-picker-state-msg">
249                    <p>{"No screen sources detected."}</p>
250                </div>
251            }
252            .into_any(),
253            (None, d_list, w_list) => {
254                let active = active_source_id.get();
255                let primary_active = active.is_none();
256                view! {
257                    <div class="screen-picker-section-label">{"Displays"}</div>
258                    <ul class="screen-picker-list" role="none">
259                        // Primary-display sentinel row (M-SCK.0 default).
260                        // Always present so the user can revert to it.
261                        <li
262                            class="screen-picker-row"
263                            role="option"
264                            aria-selected=move || primary_active
265                            data-active=move || if primary_active { "true" } else { "false" }
266                            on:click=move |_| on_pick(None)
267                        >
268                            <span class="screen-picker-row-check" aria-hidden="true">
269                                {if primary_active { "✓" } else { " " }}
270                            </span>
271                            <span class="screen-picker-row-label">{"Primary display (auto)"}</span>
272                            <span class="screen-picker-row-sub">{"First connected display"}</span>
273                        </li>
274                        {d_list
275                            .into_iter()
276                            .map(|d| {
277                                let id = d.id.clone();
278                                let is_active = active.as_deref() == Some(id.as_str());
279                                view! {
280                                    <li
281                                        class="screen-picker-row"
282                                        role="option"
283                                        aria-selected=is_active
284                                        data-active=if is_active { "true" } else { "false" }
285                                        on:click=move |_| on_pick(Some(id.clone()))
286                                    >
287                                        <span class="screen-picker-row-check" aria-hidden="true">
288                                            {if is_active { "✓" } else { " " }}
289                                        </span>
290                                        <span class="screen-picker-row-label">{d.label.clone()}</span>
291                                        <span class="screen-picker-row-sub">
292                                            {format!("{}×{}{}", d.width, d.height, if d.is_primary { " · primary" } else { "" })}
293                                        </span>
294                                    </li>
295                                }
296                            })
297                            .collect_view()}
298                    </ul>
299                    <div class="screen-picker-section-label">{"Windows"}</div>
300                    <ul class="screen-picker-list" role="none">
301                        {w_list
302                            .into_iter()
303                            .map(|w| {
304                                let id = w.id.clone();
305                                let is_active = active.as_deref() == Some(id.as_str());
306                                let title = if w.label.is_empty() { "(untitled)".to_string() } else { w.label.clone() };
307                                view! {
308                                    <li
309                                        class="screen-picker-row"
310                                        role="option"
311                                        aria-selected=is_active
312                                        data-active=if is_active { "true" } else { "false" }
313                                        on:click=move |_| on_pick(Some(id.clone()))
314                                    >
315                                        <span class="screen-picker-row-check" aria-hidden="true">
316                                            {if is_active { "✓" } else { " " }}
317                                        </span>
318                                        <span class="screen-picker-row-label">{title}</span>
319                                        <span class="screen-picker-row-sub">
320                                            {format!("{} · {}×{}", w.display_name, w.width, w.height)}
321                                        </span>
322                                    </li>
323                                }
324                            })
325                            .collect_view()}
326                    </ul>
327                }
328                .into_any()
329            }
330        }
331    }
332}
333
334#[cfg(target_arch = "wasm32")]
335fn read_last_used() -> Option<String> {
336    let storage = web_sys::window()?.local_storage().ok().flatten()?;
337    let raw = storage.get_item(LAST_USED_KEY).ok().flatten()?;
338    if raw.is_empty() { None } else { Some(raw) }
339}
340
341#[cfg(not(target_arch = "wasm32"))]
342fn read_last_used() -> Option<String> {
343    None
344}
345
346#[cfg(target_arch = "wasm32")]
347fn write_last_used(id: Option<&str>) {
348    let Some(window) = web_sys::window() else {
349        return;
350    };
351    let Ok(Some(storage)) = window.local_storage() else {
352        return;
353    };
354    match id {
355        Some(value) => {
356            let _ = storage.set_item(LAST_USED_KEY, value);
357        }
358        None => {
359            let _ = storage.remove_item(LAST_USED_KEY);
360        }
361    }
362}
363
364#[cfg(not(target_arch = "wasm32"))]
365fn write_last_used(_id: Option<&str>) {}