Skip to main content

app_ui/
recorder_page.rs

1//! `<RecorderPage />` — the live recorder surface that composes the
2//! `ui-storybook` presentational components into the design shown
3//! in the Screen-Studio-style mock (workspace badge, capture-mode
4//! tabs, display source card, camera / mic / system-audio rows with
5//! inline expandable pickers, on-screen options summary, and the
6//! recording-controls footer).
7//!
8//! The presentational components stay pure (they take view-models +
9//! optional callbacks). This module owns the live state — `RwSignal`s
10//! seeded from the `*_ipc` modules — and converts each tick of state
11//! into the matching view-model.
12//!
13//! IPC contracts (`camera_ipc`, `mic_ipc`, `screen_ipc`,
14//! `system_audio_ipc`, `recording_ipc`) are NOT touched. This is a
15//! pure render-layer rewire so the existing TCC / permission flows
16//! keep working exactly as before.
17
18use leptos::ev::MouseEvent;
19use leptos::prelude::*;
20use leptos::task::spawn_local;
21
22use ui_storybook::components::primitives::{
23    Camera, ChevronDown, IconTile, IconTileKind, Mic, Volume2,
24};
25use ui_storybook::components::recorder::{
26    AppIconView, AudioAppView as StoryAudioAppView, AudioFilter, AutoZoomSelect, CaptureModeTabs,
27    CaptureSourceKind, CaptureSourceView, CountdownSelect, DeviceOptionView, DevicePickerState,
28    DeviceThumb, DisplayPreviewFrame, DisplayPreviewView, OnScreenOptionKind, OnScreenOptionView,
29    PreviewWindowChip, SaveFormat, SavePanel, SavePanelView, StartRecordingButton,
30    StartRecordingState, SystemAudioView, format_auto_zoom_label, format_countdown_label,
31    format_selection_count,
32};
33use ui_storybook::fixtures::recorder::CaptureMode;
34
35use crate::camera_ipc::{self, CameraPermission, CameraView};
36use crate::mic_ipc::{self, MicrophoneView};
37use crate::recording_ipc::{
38    self, PendingExportView, RecordingConfigView, RecordingStatusViewIpc, SessionStreamsView,
39    default_output_path, discard_recording, export_recording, recording_pending_export,
40    recording_status, reveal_in_file_manager, start_recording, stop_recording,
41};
42use crate::screen_ipc::{self, DisplaySourceView as IpcDisplaySourceView, ScreenSourcesResult};
43use crate::settings_ipc;
44use crate::system_audio_ipc::{
45    self, AudioAppFilterView, AudioAppView as IpcAudioAppView, ListAudioAppsResult,
46};
47
48/// View-model for the display selector row.
49struct DisplaySummary {
50    name: String,
51    size_label: String,
52    dimensions_label: String,
53}
54
55/// Which expandable picker, if any, is currently open. Mutually
56/// exclusive so opening one closes the others.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
58enum OpenPicker {
59    /// Nothing expanded.
60    #[default]
61    None,
62    /// Display preview shown (Built-in Retina row clicked).
63    Display,
64    /// Camera device picker open.
65    Camera,
66    /// Microphone device picker open.
67    Microphone,
68    /// System-audio app list open.
69    SystemAudio,
70    /// On-screen options popover open.
71    OnScreen,
72}
73
74/// The full Recorder surface — exact match to the design mock.
75#[component]
76#[allow(
77    clippy::too_many_lines,
78    reason = "Top-level Leptos composition for the recorder surface; splitting helpers would lose the view! macro's single-tree expansion that keeps reactivity intact."
79)]
80pub fn RecorderPage() -> impl IntoView {
81    // -------- live state ------------------------------------------------
82    let cameras = RwSignal::new(Vec::<CameraView>::new());
83    let camera_selected = RwSignal::new(Option::<String>::None);
84    let camera_enabled = RwSignal::new(true);
85    let camera_permission = RwSignal::new(CameraPermission::Granted);
86
87    let mics = RwSignal::new(Vec::<MicrophoneView>::new());
88    let mic_selected = RwSignal::new(Option::<String>::None);
89    let mic_enabled = RwSignal::new(false);
90    let mic_permission = RwSignal::new(CameraPermission::Granted);
91    let mic_level = RwSignal::new(0.0_f32);
92
93    let displays = RwSignal::new(Vec::<IpcDisplaySourceView>::new());
94    let display_selected = RwSignal::new(Option::<String>::None);
95    let display_err = RwSignal::new(Option::<String>::None);
96
97    let audio_apps = RwSignal::new(Vec::<IpcAudioAppView>::new());
98    let audio_app_selected = RwSignal::new(Vec::<String>::new());
99    let system_audio_enabled = RwSignal::new(false);
100    let audio_filter = RwSignal::new(AudioFilter::All);
101    let audio_app_err = RwSignal::new(Option::<String>::None);
102
103    let on_screen_opts = RwSignal::new(default_on_screen_options());
104    let auto_zoom = RwSignal::new(2.0_f32);
105    let countdown_seconds = RwSignal::new(3_u8);
106
107    let capture_mode = RwSignal::new(CaptureMode::Screen);
108    // Default to Display so the preview is visible on launch; the user
109    // can click the Built-in Retina row to collapse it.
110    let open_picker = RwSignal::new(OpenPicker::Display);
111
112    let status = RwSignal::new(RecordingStatusViewIpc::idle());
113    let error_msg = RwSignal::new(Option::<String>::None);
114    // UI-local latch set the instant the user requests a stop. The
115    // 500 ms backend `recording-status` pump can emit a trailing
116    // `Stopping` event mid-teardown that lands *after* the stop
117    // handler reset the UI, re-arming the controls and leaving the
118    // recorder permanently stuck on "Stop recording". This latch
119    // gates `is_recording` so such a trailing event can't re-arm us;
120    // it's cleared when the next session starts. See `show_as_recording`.
121    let stop_requested = RwSignal::new(false);
122
123    // -------- M-SAVE.3/.4 — Save panel + ⋯ menu state -----------------
124    // The stop handler sets `pending_export` from the stop summary;
125    // `Some` swaps the record footer for the Save panel. `export_busy`
126    // gates the controls during the (software-VP9) WebM transcode;
127    // `saved_path` drives the post-export "Saved → Reveal" state.
128    let pending_export = RwSignal::new(Option::<PendingExportView>::None);
129    let export_format = RwSignal::new("mp4-h264".to_string());
130    let export_busy = RwSignal::new(false);
131    let saved_path = RwSignal::new(Option::<String>::None);
132    let output_dir = RwSignal::new(String::new());
133    // ⋯ "More options" menu — set the output folder without recording.
134    let overflow_open = RwSignal::new(false);
135
136    // -------- subscriptions -------------------------------------------
137    mic_ipc::subscribe_mic_level(move |lvl| mic_level.set(lvl));
138    install_status_listener(status);
139
140    spawn_local(async move {
141        let view = recording_status().await;
142        status.set(view);
143    });
144    // M-SAVE.3 — re-show the Save panel if a recording is awaiting
145    // export (e.g. the surface remounted after Stop) + load the
146    // configured output folder for display.
147    spawn_local(async move {
148        pending_export.set(recording_pending_export().await);
149    });
150    spawn_local(async move {
151        output_dir.set(settings_ipc::get_output_dir().await);
152    });
153    refresh_cameras(cameras, camera_selected, camera_permission);
154    refresh_mics(mics, mic_selected, mic_permission);
155    refresh_displays(displays, display_selected, display_err);
156    refresh_audio_apps(audio_apps, audio_app_err);
157
158    // ISS-05: align the bubble at mount, else first click flips opposite to camera_enabled.
159    crate::bubble_ipc::set_webcam_bubble_visibility(camera_enabled.get());
160
161    // AUT-269 — live screen preview: start/stop the downscaled preview capture
162    // while the display section is open (+ on source change) and paint each
163    // frame into the canvas overlaid on the mock below. Installed once.
164    let display_active = Memo::new(move |_| open_picker.get() == OpenPicker::Display);
165    crate::screen_preview_canvas::install_screen_preview(display_active, display_selected);
166
167    // -------- view-models ---------------------------------------------
168    let camera_view = move || -> CaptureSourceView {
169        let label = selected_camera_label(&cameras.get(), camera_selected.get().as_deref());
170        let subtitle = camera_subtitle(&cameras.get(), camera_selected.get().as_deref());
171        CaptureSourceView {
172            id: "camera".to_owned(),
173            kind: CaptureSourceKind::Camera,
174            title: label,
175            subtitle,
176            enabled: camera_enabled.get(),
177            expanded: open_picker.get() == OpenPicker::Camera,
178            favorite: false,
179            level: None,
180        }
181    };
182
183    let mic_view = move || -> CaptureSourceView {
184        let label = selected_mic_label(&mics.get(), mic_selected.get().as_deref());
185        let subtitle = mic_subtitle(&mics.get(), mic_selected.get().as_deref());
186        CaptureSourceView {
187            id: "microphone".to_owned(),
188            kind: CaptureSourceKind::Microphone,
189            title: label,
190            subtitle,
191            enabled: mic_enabled.get(),
192            expanded: open_picker.get() == OpenPicker::Microphone,
193            favorite: true,
194            level: Some(mic_level.get().clamp(0.0, 1.0)),
195        }
196    };
197
198    let display_summary = move || -> DisplaySummary {
199        let list = displays.get();
200        let selected = display_selected.get();
201        let chosen = list.iter().find(|d| Some(&d.id) == selected.as_ref());
202        let primary = list.iter().find(|d| d.is_primary);
203        let active = chosen.or(primary).or_else(|| list.first());
204        // ScreenCaptureKit reports points; double to physical pixels
205        // (every modern Mac display is 2× Retina) for the label.
206        let (w_pt, h_pt) = active.map_or((1920, 1080), |d| (d.width, d.height));
207        let (w, h) = (w_pt * 2, h_pt * 2);
208        // AUT-271: use the real display name (was hardcoded "Built-in Retina",
209        // so every monitor showed that), falling back when none is detected.
210        let name = active.map_or_else(|| "No display detected".to_owned(), display_label);
211        DisplaySummary {
212            name,
213            size_label: format_display_size(w, h),
214            dimensions_label: format!("{w} × {h}"),
215        }
216    };
217
218    let display_preview_view = move || -> DisplayPreviewView {
219        let list = displays.get();
220        let selected = display_selected.get();
221        let chosen = list.iter().find(|d| Some(&d.id) == selected.as_ref());
222        let primary = list.iter().find(|d| d.is_primary);
223        let active = chosen.or(primary).or_else(|| list.first());
224        let (w_pt, h_pt) = active.map_or((1920, 1080), |d| (d.width, d.height));
225        let (w, h) = (w_pt * 2, h_pt * 2);
226        DisplayPreviewView {
227            aspect_ratio: aspect_ratio_for(w, h),
228            // Suppressed; the dim badge is drawn by `.recorder-display-preview-badge`.
229            overlay_label: None,
230            mock_windows: vec![PreviewWindowChip {
231                label: "Active window".to_owned(),
232                color: "rgba(200, 200, 200, 0.55)".to_owned(),
233                left_pct: 12,
234                top_pct: 14,
235                width_pct: 72,
236                height_pct: 60,
237            }],
238        }
239    };
240
241    let system_audio_view = move || -> SystemAudioView {
242        let apps = audio_apps.get();
243        let selected = audio_app_selected.get();
244        let total = apps.len();
245        let icon_stack: Vec<AppIconView> = apps
246            .iter()
247            .filter(|a| selected.iter().any(|b| b == &a.bundle_id))
248            .take(4)
249            .map(|a| AppIconView {
250                id: a.bundle_id.clone(),
251                monogram: app_monogram(&a.display_name),
252                color: app_color_for(&a.bundle_id),
253            })
254            .collect();
255        SystemAudioView {
256            enabled: system_audio_enabled.get(),
257            expanded: open_picker.get() == OpenPicker::SystemAudio,
258            selected_count: selected.len(),
259            total_count: total,
260            icon_stack,
261        }
262    };
263
264    let on_screen_summary = move || -> String {
265        let opts = on_screen_opts.get();
266        let enabled: Vec<String> = opts
267            .iter()
268            .filter(|o| o.enabled)
269            .map(|o| o.title.clone())
270            .collect();
271        if enabled.is_empty() {
272            "Off".to_owned()
273        } else {
274            format!("{} on", enabled.len())
275        }
276    };
277
278    let start_state = move || {
279        let any_source = camera_enabled.get()
280            || mic_enabled.get()
281            || system_audio_enabled.get()
282            || !displays.get().is_empty();
283        if any_source {
284            StartRecordingState::Ready
285        } else {
286            StartRecordingState::Disabled
287        }
288    };
289    let shortcut_keys = || vec!["⌘".to_owned(), "⇧".to_owned(), "2".to_owned()];
290    let auto_zoom_label = move || format_auto_zoom_label(Some(auto_zoom.get()));
291    let countdown_label = move || format_countdown_label(countdown_seconds.get());
292    let elapsed_label = move || {
293        let total = status.get().elapsed_ms / 1000;
294        let mm = total / 60;
295        let ss = total % 60;
296        format!("{mm:02}:{ss:02}")
297    };
298    let is_recording = move || show_as_recording(stop_requested.get(), status.get().is_recording());
299
300    // -------- callbacks -----------------------------------------------
301    let toggle_camera_picker = move || {
302        let next = if open_picker.get() == OpenPicker::Camera {
303            OpenPicker::None
304        } else {
305            OpenPicker::Camera
306        };
307        open_picker.set(next);
308        if next == OpenPicker::Camera {
309            refresh_cameras(cameras, camera_selected, camera_permission);
310        }
311    };
312    let toggle_mic_picker = move || {
313        let next = if open_picker.get() == OpenPicker::Microphone {
314            OpenPicker::None
315        } else {
316            OpenPicker::Microphone
317        };
318        open_picker.set(next);
319        if next == OpenPicker::Microphone {
320            refresh_mics(mics, mic_selected, mic_permission);
321        }
322    };
323    let toggle_audio_picker = move || {
324        let next = if open_picker.get() == OpenPicker::SystemAudio {
325            OpenPicker::None
326        } else {
327            OpenPicker::SystemAudio
328        };
329        open_picker.set(next);
330        if next == OpenPicker::SystemAudio {
331            refresh_audio_apps(audio_apps, audio_app_err);
332        }
333    };
334    let toggle_on_screen = move || {
335        let next = if open_picker.get() == OpenPicker::OnScreen {
336            OpenPicker::None
337        } else {
338            OpenPicker::OnScreen
339        };
340        open_picker.set(next);
341    };
342    let toggle_display = move || {
343        let next = if open_picker.get() == OpenPicker::Display {
344            OpenPicker::None
345        } else {
346            OpenPicker::Display
347        };
348        open_picker.set(next);
349    };
350
351    let on_camera_toggle = move |_: MouseEvent| {
352        let next = !camera_enabled.get();
353        camera_enabled.set(next);
354        if next && camera_selected.get().is_none() {
355            let list = cameras.get();
356            if let Some(c) = list.first() {
357                let id = c.id.clone();
358                camera_selected.set(Some(id.clone()));
359                spawn_local(async move {
360                    camera_ipc::start_preview(id).await;
361                });
362            }
363        }
364        // The webcam preview lives in a separate borderless Tauri
365        // window. Drive its visibility from `next` via the explicit
366        // setter so `camera_enabled` and the bubble stay in lockstep
367        // across mount + rail-surface navigation — the old toggle
368        // call assumed the two started in sync, but they don't (ISS-05).
369        crate::bubble_ipc::set_webcam_bubble_visibility(next);
370    };
371    let on_mic_toggle = move |_: MouseEvent| {
372        let next = !mic_enabled.get();
373        mic_enabled.set(next);
374        if next && mic_selected.get().is_none() {
375            let list = mics.get();
376            if let Some(m) = list.first() {
377                let id = m.id.clone();
378                mic_selected.set(Some(id.clone()));
379                spawn_local(async move {
380                    mic_ipc::start_mic_capture(id).await;
381                });
382            }
383        }
384        if !next {
385            spawn_local(async move {
386                mic_ipc::stop_mic_capture().await;
387            });
388        }
389    };
390    let on_system_audio_toggle = move |_: MouseEvent| {
391        let next = !system_audio_enabled.get();
392        system_audio_enabled.set(next);
393        spawn_local(async move {
394            if next {
395                let _ = system_audio_ipc::start_system_audio_capture().await;
396            } else {
397                system_audio_ipc::stop_system_audio_capture().await;
398            }
399        });
400    };
401
402    let on_camera_select = move |id: String| {
403        camera_selected.set(Some(id.clone()));
404        open_picker.set(OpenPicker::None);
405        spawn_local(async move {
406            camera_ipc::start_preview(id).await;
407        });
408    };
409    let on_mic_select = move |id: String| {
410        mic_selected.set(Some(id.clone()));
411        open_picker.set(OpenPicker::None);
412        spawn_local(async move {
413            mic_ipc::start_mic_capture(id).await;
414        });
415    };
416    let on_audio_app_toggle = move |bundle_id: String| {
417        let mut cur = audio_app_selected.get();
418        if let Some(pos) = cur.iter().position(|b| b == &bundle_id) {
419            cur.remove(pos);
420        } else {
421            cur.push(bundle_id);
422        }
423        audio_app_selected.set(cur.clone());
424        spawn_local(async move {
425            let filter = if cur.is_empty() {
426                AudioAppFilterView::AllAudio
427            } else {
428                AudioAppFilterView::OnlyApps(cur)
429            };
430            let _ = system_audio_ipc::set_system_audio_filter(filter).await;
431        });
432    };
433    let on_audio_filter = move |f: AudioFilter| {
434        audio_filter.set(f);
435        let apps = audio_apps.get();
436        match f {
437            AudioFilter::All => {
438                let all: Vec<String> = apps.iter().map(|a| a.bundle_id.clone()).collect();
439                audio_app_selected.set(all);
440            }
441            AudioFilter::None => {
442                audio_app_selected.set(Vec::new());
443            }
444            AudioFilter::Suggested => {
445                let suggested: Vec<String> = apps
446                    .iter()
447                    .filter(|a| is_suggested_app(&a.bundle_id))
448                    .map(|a| a.bundle_id.clone())
449                    .collect();
450                audio_app_selected.set(suggested);
451            }
452        }
453    };
454    let on_screen_toggle = move |kind: OnScreenOptionKind| {
455        let mut opts = on_screen_opts.get();
456        for o in &mut opts {
457            if o.id == kind {
458                o.enabled = !o.enabled;
459            }
460        }
461        on_screen_opts.set(opts);
462    };
463
464    let on_start = Callback::new(move |()| {
465        if is_recording() {
466            // Latch immediately so any trailing `Stopping` status
467            // event can't re-arm the controls while the async stop is
468            // in flight (the post-stop "stuck" bug).
469            stop_requested.set(true);
470            spawn_local(async move {
471                // On success the summary carries the awaiting-export
472                // handoff (M-SAVE.1) → drives the Save panel. The only
473                // Err is "no recording session is active" (already
474                // gone); either way reset the live recording UI below.
475                if let Ok(summary) = stop_recording().await {
476                    pending_export.set(summary.pending_export);
477                }
478                error_msg.set(None);
479                status.set(RecordingStatusViewIpc::idle());
480            });
481            return;
482        }
483        // Clear the stop latch so the next session's `Running` event
484        // can re-arm the controls.
485        stop_requested.set(false);
486        // Clear any lingering Save-panel success state from a prior run.
487        saved_path.set(None);
488        let cam = camera_enabled.get();
489        let mic = mic_enabled.get();
490        let sys = system_audio_enabled.get();
491        let cam_id = camera_selected.get();
492        let mic_id = mic_selected.get();
493        let screen_id = display_selected.get();
494        spawn_local(async move {
495            let path = default_output_path(Some("mp4-h264")).await;
496            let config = RecordingConfigView {
497                streams: SessionStreamsView {
498                    camera: cam,
499                    screen: true,
500                    microphone: mic,
501                    system_audio: sys,
502                },
503                camera_id: cam_id.unwrap_or_default(),
504                microphone_id: mic_id.unwrap_or_default(),
505                screen_source_id: screen_id,
506                output_path: if path.is_empty() { None } else { Some(path) },
507                format: Some("mp4-h264".to_owned()),
508            };
509            match start_recording(config).await {
510                Ok(_) => error_msg.set(None),
511                Err(err) => error_msg.set(Some(err)),
512            }
513        });
514    });
515
516    // -------- M-SAVE.3 — Save panel callbacks -------------------------
517    let on_export = Callback::new(move |()| {
518        let fmt = export_format.get();
519        export_busy.set(true);
520        error_msg.set(None);
521        spawn_local(async move {
522            match export_recording(Some(fmt.as_str()), None).await {
523                Ok(path) => {
524                    pending_export.set(None);
525                    saved_path.set(Some(path));
526                }
527                // The backend restores the pending export on failure,
528                // so the panel stays up for a retry.
529                Err(err) => error_msg.set(Some(err)),
530            }
531            export_busy.set(false);
532        });
533    });
534
535    // "Edit" — save the recording as MP4 (the editable intermediate, also
536    // landed in the recordings folder) and open it in the editor. The
537    // `editor-project` reply trips the app-root effect that switches to the
538    // Editor tab. Forces MP4 regardless of the chosen export format: that
539    // choice is for the *final* export from the editor.
540    let on_edit = Callback::new(move |()| {
541        export_busy.set(true);
542        error_msg.set(None);
543        spawn_local(async move {
544            match export_recording(Some("mp4-h264"), None).await {
545                Ok(path) => {
546                    pending_export.set(None);
547                    saved_path.set(None);
548                    let _ = crate::editor_ipc::screen_open_in_editor(&path);
549                }
550                Err(err) => error_msg.set(Some(err)),
551            }
552            export_busy.set(false);
553        });
554    });
555
556    let on_discard = Callback::new(move |()| {
557        spawn_local(async move {
558            discard_recording().await;
559            pending_export.set(None);
560            saved_path.set(None);
561            error_msg.set(None);
562        });
563    });
564
565    // The Save panel's format dropdown is a controlled `<select>` —
566    // it fires `SaveFormat`; we stash the slug back into the signal the
567    // export handler reads.
568    let on_format_change =
569        Callback::new(move |fmt: SaveFormat| export_format.set(fmt.slug().to_owned()));
570
571    let on_change_folder = Callback::new(move |()| {
572        spawn_local(async move {
573            if let Some(dir) = settings_ipc::pick_output_dir().await {
574                if let Err(err) = settings_ipc::set_output_dir(&dir).await {
575                    error_msg.set(Some(err));
576                } else {
577                    output_dir.set(dir);
578                }
579            }
580        });
581    });
582
583    let on_reveal = Callback::new(move |()| {
584        let Some(path) = saved_path.get() else { return };
585        spawn_local(async move {
586            let _ = reveal_in_file_manager(&path).await;
587        });
588    });
589
590    let on_dismiss_saved = Callback::new(move |()| saved_path.set(None));
591
592    // -------- view ----------------------------------------------------
593    view! {
594        <section
595            class="recorder-page"
596            data-mode=move || capture_mode_slug(capture_mode.get())
597            data-recording=move || if is_recording() { "true" } else { "false" }
598        >
599            <Show when=is_recording fallback=|| view! { <></> }>
600                <div class="recorder-page-recording-pill" role="status" aria-live="polite">
601                    <span class="recorder-page-recording-dot" aria-hidden="true"></span>
602                    <span class="recorder-page-recording-label">"RECORDING"</span>
603                    <span class="recorder-page-recording-elapsed">
604                        {move || elapsed_label()}
605                    </span>
606                </div>
607            </Show>
608            <header class="recorder-page-header">
609                <div class="recorder-page-tabs">
610                    <CaptureModeTabs selected=capture_mode.get() />
611                </div>
612                <div class="recorder-overflow-wrap">
613                    <button
614                        type="button"
615                        class="recorder-page-overflow"
616                        aria-label="More options"
617                        aria-haspopup="menu"
618                        aria-expanded=move || if overflow_open.get() { "true" } else { "false" }
619                        on:click=move |_| overflow_open.update(|o| *o = !*o)
620                    >
621                        "⋯"
622                    </button>
623                    <Show when=move || overflow_open.get() fallback=|| view! { <></> }>
624                        <div class="recorder-overflow-menu" role="menu">
625                            <div class="recorder-overflow-key">"Recording folder"</div>
626                            <div class="recorder-overflow-folder" title=move || output_dir.get()>
627                                {move || output_dir.get()}
628                            </div>
629                            <button
630                                type="button"
631                                class="recorder-overflow-change"
632                                role="menuitem"
633                                on:click=move |_| {
634                                    on_change_folder.run(());
635                                    overflow_open.set(false);
636                                }
637                            >"Change folder…"</button>
638                        </div>
639                    </Show>
640                </div>
641            </header>
642
643            <div class="recorder-page-body">
644                <section class="recorder-page-display">
645                    {move || {
646                        let summary = display_summary();
647                        view! {
648                            <button
649                                type="button"
650                                class="recorder-display-selector"
651                                aria-label="Toggle display preview"
652                                aria-expanded=move || open_picker.get() == OpenPicker::Display
653                                on:click=move |_| toggle_display()
654                            >
655                                <span class="recorder-display-selector-swatch" aria-hidden="true"></span>
656                                <span class="recorder-display-selector-label">
657                                    <span class="recorder-display-selector-name">{summary.name}</span>
658                                    <span class="recorder-display-selector-size">{summary.size_label}</span>
659                                    <span class="recorder-display-selector-star" aria-label="Favourite" title="Favourite">"☆"</span>
660                                </span>
661                                <span
662                                    class="recorder-display-selector-chevron"
663                                    data-open=move || if open_picker.get() == OpenPicker::Display { "true" } else { "false" }
664                                    aria-hidden="true"
665                                >
666                                    <ChevronDown />
667                                </span>
668                            </button>
669                        }
670                    }}
671                    <Show when=move || open_picker.get() == OpenPicker::Display fallback=|| view! { <></> }>
672                        // AUT-271: pick which display to record. Clicking a row
673                        // sets `display_selected`, which feeds the recording's
674                        // `screen_source_id` — so the recording captures the
675                        // chosen monitor, not always the primary.
676                        <ul class="recorder-display-list" role="listbox" aria-label="Choose a display">
677                            {move || {
678                                let selected = display_selected.get();
679                                displays
680                                    .get()
681                                    .into_iter()
682                                    .map(|d| {
683                                        let id = d.id.clone();
684                                        let is_active = selected.as_deref() == Some(id.as_str());
685                                        let sub = format!(
686                                            "{} × {}{}",
687                                            d.width * 2,
688                                            d.height * 2,
689                                            if d.is_primary { " · primary" } else { "" },
690                                        );
691                                        view! {
692                                            <li
693                                                class="recorder-display-row"
694                                                role="option"
695                                                aria-selected=is_active
696                                                data-active=if is_active { "true" } else { "false" }
697                                                on:click=move |_| display_selected.set(Some(id.clone()))
698                                            >
699                                                <span class="recorder-display-row-check" aria-hidden="true">
700                                                    {if is_active { "✓" } else { " " }}
701                                                </span>
702                                                <span class="recorder-display-row-label">
703                                                    {display_label(&d)}
704                                                </span>
705                                                <span class="recorder-display-row-sub">{sub}</span>
706                                            </li>
707                                        }
708                                    })
709                                    .collect_view()
710                            }}
711                        </ul>
712                        {move || {
713                            let dims = display_summary().dimensions_label;
714                            view! {
715                                <div class="recorder-display-preview-wrap" aria-label="Preview">
716                                    <DisplayPreviewFrame view=display_preview_view() />
717                                    // AUT-269 — live downscaled screen preview,
718                                    // overlaid on the mock. Transparent until the
719                                    // first frame arrives (mock shows through),
720                                    // then the real screen paints over it.
721                                    <canvas
722                                        id=crate::screen_preview_canvas::SCREEN_PREVIEW_CANVAS_ID
723                                        class="recorder-display-preview-canvas"
724                                        width=crate::screen_preview_canvas::SCREEN_PREVIEW_WIDTH
725                                        height=crate::screen_preview_canvas::SCREEN_PREVIEW_HEIGHT
726                                        aria-label="Live screen preview"
727                                    />
728                                    <span class="recorder-display-preview-badge">{dims}</span>
729                                </div>
730                            }
731                        }}
732                    </Show>
733                    <Show when=move || display_err.get().is_some() fallback=|| view! { <></> }>
734                        <DisplayError msg=display_err />
735                    </Show>
736                </section>
737
738                <section class="recorder-page-sources">
739                    <LiveSourceRow
740                        view=Signal::derive(camera_view)
741                        on_chevron_click=Callback::new(move |()| toggle_camera_picker())
742                        on_toggle=Callback::new(move |()| on_camera_toggle(MouseEvent::new("click").unwrap()))
743                    />
744                    <Show when=move || open_picker.get() == OpenPicker::Camera fallback=|| view! { <></> }>
745                        <div class="recorder-page-picker recorder-page-picker--camera">
746                            <LiveDevicePicker
747                                kind=CaptureSourceKind::Camera
748                                state=Signal::derive(move || device_state_for(camera_permission.get(), cameras.get().is_empty()))
749                                devices=Signal::derive(move || camera_device_options(cameras.get(), camera_selected.get()))
750                                on_select=Callback::new(on_camera_select)
751                                on_grant_permission=Callback::new(move |()| {
752                                    spawn_local(async move {
753                                        let _ = camera_ipc::request_all_permissions().await;
754                                    });
755                                })
756                            />
757                        </div>
758                    </Show>
759                    // Per the design, the live webcam canvas lives in
760                    // the floating webcam-bubble window, not inline
761                    // inside the recorder panel. on_camera_toggle
762                    // shows / hides that window via bubble_ipc.
763
764                    <LiveSourceRow
765                        view=Signal::derive(mic_view)
766                        on_chevron_click=Callback::new(move |()| toggle_mic_picker())
767                        on_toggle=Callback::new(move |()| on_mic_toggle(MouseEvent::new("click").unwrap()))
768                    />
769                    <Show when=move || open_picker.get() == OpenPicker::Microphone fallback=|| view! { <></> }>
770                        <div class="recorder-page-picker recorder-page-picker--mic">
771                            <LiveDevicePicker
772                                kind=CaptureSourceKind::Microphone
773                                state=Signal::derive(move || device_state_for(mic_permission.get(), mics.get().is_empty()))
774                                devices=Signal::derive(move || mic_device_options(mics.get(), mic_selected.get()))
775                                on_select=Callback::new(on_mic_select)
776                                on_grant_permission=Callback::new(move |()| {
777                                    spawn_local(async move {
778                                        let _ = camera_ipc::request_all_permissions().await;
779                                    });
780                                })
781                            />
782                        </div>
783                    </Show>
784                </section>
785
786                <section class="recorder-page-audio">
787                    <LiveSystemAudioRow
788                        view=Signal::derive(system_audio_view)
789                        on_chevron_click=Callback::new(move |()| toggle_audio_picker())
790                        on_toggle=Callback::new(move |()| on_system_audio_toggle(MouseEvent::new("click").unwrap()))
791                    />
792                    <Show when=move || open_picker.get() == OpenPicker::SystemAudio fallback=|| view! { <></> }>
793                        <div class="recorder-page-applist">
794                            <LiveAudioAppList
795                                apps=Signal::derive(move || audio_app_view_list(
796                                    audio_apps.get(),
797                                    audio_app_selected.get(),
798                                ))
799                                active_filter=Signal::derive(move || audio_filter.get())
800                                on_filter=Callback::new(on_audio_filter)
801                                on_app_toggle=Callback::new(on_audio_app_toggle)
802                            />
803                            <Show when=move || audio_app_err.get().is_some() fallback=|| view! { <></> }>
804                                <p class="recorder-page-audio-error">
805                                    {move || audio_app_err.get().unwrap_or_default()}
806                                </p>
807                            </Show>
808                        </div>
809                    </Show>
810                </section>
811
812                <section class="recorder-page-on-screen">
813                    <button
814                        class="recorder-page-on-screen-row"
815                        type="button"
816                        aria-haspopup="dialog"
817                        aria-expanded=move || open_picker.get() == OpenPicker::OnScreen
818                        on:click=move |_| toggle_on_screen()
819                    >
820                        <IconTile kind=IconTileKind::Action>"✦"</IconTile>
821                        <span class="recorder-page-on-screen-text">
822                            <span class="recorder-page-on-screen-title">"On-screen"</span>
823                            <span class="recorder-page-on-screen-summary">
824                                {move || on_screen_summary()}
825                            </span>
826                        </span>
827                        <span
828                            class="recorder-page-chevron"
829                            data-open=move || if open_picker.get() == OpenPicker::OnScreen { "true" } else { "false" }
830                            aria-hidden="true"
831                        >
832                            <ChevronDown />
833                        </span>
834                    </button>
835                    <Show when=move || open_picker.get() == OpenPicker::OnScreen fallback=|| view! { <></> }>
836                        <div class="recorder-page-on-screen-popover">
837                            <LiveOnScreenPopover
838                                opts=Signal::derive(move || on_screen_opts.get())
839                                on_toggle=Callback::new(on_screen_toggle)
840                            />
841                        </div>
842                    </Show>
843                </section>
844
845            </div>
846
847            // M-SAVE.3 — after Stop, the recording is parked awaiting
848            // export: the Save panel replaces the record/stop footer
849            // until the user exports or discards (a finished recording
850            // also blocks starting a new one, so hiding Record is
851            // correct). `saved_path` keeps the panel up in its success
852            // state after a successful export until "Done" is clicked.
853            <Show
854                when=move || save_panel_visible(pending_export.get().is_some(), saved_path.get().is_some())
855                fallback=move || view! {
856                    <footer class="recorder-page-action-bar">
857                        <div class="recorder-page-controls" aria-label="Recording options">
858                            {move || view! { <AutoZoomSelect label=auto_zoom_label() /> }}
859                            {move || view! { <CountdownSelect label=countdown_label() /> }}
860                        </div>
861                        <Show
862                            when=is_recording
863                            fallback=move || view! {
864                                <div class="recording-controls-footer" data-state="ready">
865                                    <div class="recording-controls-action">
866                                        {move || view! {
867                                            <StartRecordingButton
868                                                state=start_state()
869                                                shortcuts=shortcut_keys()
870                                                on_start=on_start
871                                            />
872                                        }}
873                                    </div>
874                                </div>
875                            }
876                        >
877                            <div class="recording-controls-footer recording-controls-footer-stop" data-state="recording">
878                                <div class="recording-controls-selects">
879                                    <span class="recorder-page-recording-elapsed">
880                                        {move || elapsed_label()}
881                                    </span>
882                                </div>
883                                <div class="recording-controls-action">
884                                    <button
885                                        type="button"
886                                        class="start-recording-btn start-recording-stop"
887                                        aria-label="Stop recording"
888                                        on:click=move |_| on_start.run(())
889                                    >
890                                        <span class="start-recording-glyph" aria-hidden="true">"■"</span>
891                                        <span class="start-recording-label">"Stop recording"</span>
892                                    </button>
893                                </div>
894                            </div>
895                        </Show>
896                    </footer>
897                }
898            >
899                // Presentational `SavePanel` (ui-storybook) wrapped in a
900                // reactive closure so it re-renders as `saved_path` /
901                // `export_format` / `export_busy` / `output_dir` change.
902                // A `Some(saved_path)` takes precedence (the post-export
903                // confirmation, dismissed via "Done"); otherwise it's
904                // the choosing/exporting state.
905                {move || {
906                    let panel_view = if let Some(path) = saved_path.get() {
907                        SavePanelView::Saved { path }
908                    } else {
909                        SavePanelView::Choosing {
910                            output_dir: output_dir.get(),
911                            format: SaveFormat::from_slug(&export_format.get()).unwrap_or_default(),
912                            busy: export_busy.get(),
913                        }
914                    };
915                    view! {
916                        <SavePanel
917                            view=panel_view
918                            on_change_folder=on_change_folder
919                            on_format_change=on_format_change
920                            on_discard=on_discard
921                            on_edit=on_edit
922                            on_export=on_export
923                            on_reveal=on_reveal
924                            on_done=on_dismiss_saved
925                        />
926                    }
927                }}
928            </Show>
929
930            <Show when=move || error_msg.get().is_some() fallback=|| view! { <></> }>
931                <div class="recorder-page-error" role="alert">
932                    {move || error_msg.get().unwrap_or_default()}
933                </div>
934            </Show>
935        </section>
936    }
937}
938
939// ---------------------------------------------------------------------
940// helper components (small render-only utilities for the page above)
941// ---------------------------------------------------------------------
942
943#[component]
944fn DisplayError(msg: RwSignal<Option<String>>) -> impl IntoView {
945    view! {
946        <p class="recorder-page-display-error" role="alert">
947            {move || msg.get().unwrap_or_default()}
948        </p>
949    }
950}
951
952/// Live, click-wired equivalent of `ui_storybook::components::recorder::CaptureSourceRow`.
953///
954/// Uses the same CSS class names as the storybook component so it
955/// renders identically; the difference is that every interactive
956/// surface (chevron button, toggle button) is a real `<button>` with
957/// its own `on:click` handler. The previous transparent-overlay
958/// approach didn't work because the overlays spanned the full row and
959/// the toggle's stopPropagation didn't beat sibling DOM order.
960#[component]
961fn LiveSourceRow(
962    view: Signal<CaptureSourceView>,
963    on_chevron_click: Callback<()>,
964    on_toggle: Callback<()>,
965) -> impl IntoView {
966    view! {
967        {move || {
968            let v = view.get();
969            let mut class = String::from("capture-source-row");
970            if v.expanded {
971                class.push_str(" capture-source-row-expanded");
972            }
973            if !v.enabled {
974                class.push_str(" capture-source-row-off");
975            }
976            let chevron_class = if v.expanded {
977                "capture-source-chevron capture-source-chevron-open"
978            } else {
979                "capture-source-chevron"
980            };
981            let kind_attr = match v.kind {
982                CaptureSourceKind::Camera => "camera",
983                CaptureSourceKind::Microphone => "microphone",
984            };
985            let title = v.title.clone();
986            let subtitle = v.subtitle.clone();
987            let toggle_class = if v.enabled {
988                "toggle-switch toggle-switch-checked"
989            } else {
990                "toggle-switch"
991            };
992            let toggle_aria = format!("Enable {}", v.kind.label());
993            view! {
994                <div class=class data-kind=kind_attr>
995                    <span class="capture-source-leading">
996                        <span class="icon-tile icon-tile-device" aria-hidden="true">
997                            {match v.kind {
998                                CaptureSourceKind::Camera => view! { <Camera /> }.into_any(),
999                                CaptureSourceKind::Microphone => view! { <Mic /> }.into_any(),
1000                            }}
1001                        </span>
1002                    </span>
1003                    <span class="capture-source-text">
1004                        <span class="capture-source-title">
1005                            <span class="capture-source-title-text">{title}</span>
1006                            <span class="capture-source-star" aria-label="Favourite" title="Favourite">
1007                                {if v.favorite { "★" } else { "☆" }}
1008                            </span>
1009                        </span>
1010                        <span class="capture-source-subtitle">{subtitle}</span>
1011                        {v.level.filter(|_| v.kind == CaptureSourceKind::Microphone).map(|l| view! {
1012                            <span class="capture-source-meter">
1013                                <span class="meter" data-level=format!("{:.2}", l)>
1014                                    {(0_u8..10).map(|i| {
1015                                        let on = f32::from(i) / 10.0 < l;
1016                                        view! {
1017                                            <span class=if on { "meter-bar meter-bar-on" } else { "meter-bar" }></span>
1018                                        }
1019                                    }).collect_view()}
1020                                </span>
1021                            </span>
1022                        })}
1023                    </span>
1024                    <button
1025                        type="button"
1026                        class=chevron_class
1027                        aria-label="Expand device picker"
1028                        aria-expanded=v.expanded
1029                        on:click=move |evt: MouseEvent| {
1030                            evt.stop_propagation();
1031                            on_chevron_click.run(());
1032                        }
1033                    >
1034                        <ChevronDown />
1035                    </button>
1036                    <button
1037                        type="button"
1038                        class=toggle_class
1039                        role="switch"
1040                        aria-checked=v.enabled
1041                        aria-label=toggle_aria
1042                        on:click=move |evt: MouseEvent| {
1043                            evt.stop_propagation();
1044                            on_toggle.run(());
1045                        }
1046                    >
1047                        <span class="toggle-switch-thumb" aria-hidden="true"></span>
1048                    </button>
1049                </div>
1050            }
1051        }}
1052    }
1053}
1054
1055/// Live, click-wired system-audio row. Mirrors
1056/// `SystemAudioRow`'s HTML for visual fidelity but routes the toggle
1057/// + expand clicks to proper callbacks.
1058#[component]
1059fn LiveSystemAudioRow(
1060    view: Signal<SystemAudioView>,
1061    on_chevron_click: Callback<()>,
1062    on_toggle: Callback<()>,
1063) -> impl IntoView {
1064    view! {
1065        {move || {
1066            let v = view.get();
1067            let chevron_class = if v.expanded {
1068                "system-audio-chevron system-audio-chevron-open"
1069            } else {
1070                "system-audio-chevron"
1071            };
1072            let count_label = format_selection_count(v.selected_count, v.total_count);
1073            let icons = v.icon_stack.clone();
1074            let visible = icons.iter().take(3).cloned().collect::<Vec<_>>();
1075            let overflow = icons.len().saturating_sub(visible.len());
1076            let toggle_class = if v.enabled {
1077                "toggle-switch toggle-switch-checked"
1078            } else {
1079                "toggle-switch"
1080            };
1081            view! {
1082                <div class="system-audio-row">
1083                    <span class="system-audio-leading">
1084                        <span class="icon-tile icon-tile-device" aria-hidden="true">
1085                            <Volume2 />
1086                        </span>
1087                    </span>
1088                    <span class="system-audio-text">
1089                        <span class="system-audio-title">
1090                            "System audio"
1091                            <span class="system-audio-inline-icons" aria-hidden="true">
1092                                {visible.into_iter().map(|icon| {
1093                                    let style = format!("background:{}", icon.color);
1094                                    let monogram = icon.monogram.clone();
1095                                    view! {
1096                                        <span class="system-audio-icon" style=style>{monogram}</span>
1097                                    }
1098                                }).collect_view()}
1099                                {(overflow > 0).then(|| {
1100                                    let label = format!("+{overflow}");
1101                                    view! { <span class="system-audio-icon-overflow">{label}</span> }
1102                                })}
1103                            </span>
1104                            <span class="capture-source-star" aria-label="Favourite" title="Favourite">"☆"</span>
1105                        </span>
1106                        <span class="system-audio-subtitle">{count_label}</span>
1107                    </span>
1108                    <button
1109                        type="button"
1110                        class=chevron_class
1111                        aria-label="Expand system audio app list"
1112                        aria-expanded=v.expanded
1113                        on:click=move |evt: MouseEvent| {
1114                            evt.stop_propagation();
1115                            on_chevron_click.run(());
1116                        }
1117                    >
1118                        <ChevronDown />
1119                    </button>
1120                    <button
1121                        type="button"
1122                        class=toggle_class
1123                        role="switch"
1124                        aria-checked=v.enabled
1125                        aria-label="Enable system audio"
1126                        on:click=move |evt: MouseEvent| {
1127                            evt.stop_propagation();
1128                            on_toggle.run(());
1129                        }
1130                    >
1131                        <span class="toggle-switch-thumb" aria-hidden="true"></span>
1132                    </button>
1133                </div>
1134            }
1135        }}
1136    }
1137}
1138
1139/// Live, click-wired device-picker (camera + mic). Each row is a real
1140/// `<button>` that fires `on_select(id)` when clicked.
1141#[component]
1142fn LiveDevicePicker(
1143    kind: CaptureSourceKind,
1144    state: Signal<DevicePickerState>,
1145    devices: Signal<Vec<DeviceOptionView>>,
1146    on_select: Callback<String>,
1147    on_grant_permission: Callback<()>,
1148) -> impl IntoView {
1149    view! {
1150        {move || {
1151            let body: AnyView = match state.get() {
1152                DevicePickerState::PermissionNeeded => view! {
1153                    <div class="device-picker-state">
1154                        <div class="device-picker-state-glyph" aria-hidden="true">"⚠"</div>
1155                        <div class="device-picker-state-title">
1156                            {match kind {
1157                                CaptureSourceKind::Camera => "Camera access required",
1158                                CaptureSourceKind::Microphone => "Microphone access required",
1159                            }}
1160                        </div>
1161                        <div class="device-picker-state-subtitle">
1162                            "Grant access in System Settings → Privacy & Security, then re-open this menu."
1163                        </div>
1164                        <button
1165                            type="button"
1166                            class="btn btn-default btn-sm"
1167                            style="margin-top:8px"
1168                            on:click=move |_| on_grant_permission.run(())
1169                        >"Grant access"</button>
1170                    </div>
1171                }.into_any(),
1172                DevicePickerState::Empty => view! {
1173                    <div class="device-picker-state">
1174                        <div class="device-picker-state-glyph" aria-hidden="true">"⚠"</div>
1175                        <div class="device-picker-state-title">"No devices detected"</div>
1176                        <div class="device-picker-state-subtitle">
1177                            "Plug in or pair a device, then re-open this menu."
1178                        </div>
1179                    </div>
1180                }.into_any(),
1181                DevicePickerState::Populated => view! {
1182                    <ul class="device-picker-rows" role="menu">
1183                        {devices.get().into_iter().map(|opt| {
1184                            let mut class = String::from("device-picker-row");
1185                            if opt.selected {
1186                                class.push_str(" device-picker-row-selected");
1187                            }
1188                            let id = opt.id.clone();
1189                            let id_for_aria = opt.id.clone();
1190                            let level_view = opt.level.map(|l| view! {
1191                                <span class="device-picker-row-meter">
1192                                    <span class="meter">
1193                                        {(0_u8..10).map(|i| {
1194                                            let on = f32::from(i) / 10.0 < l;
1195                                            view! {
1196                                                <span class=if on { "meter-bar meter-bar-on" } else { "meter-bar" }></span>
1197                                            }
1198                                        }).collect_view()}
1199                                    </span>
1200                                </span>
1201                            });
1202                            let thumb_view = opt.thumbnail.as_ref().map(|t| {
1203                                let style = format!("background:{}", t.background);
1204                                let glyph = t.glyph.clone();
1205                                view! {
1206                                    <span class="device-picker-thumb" style=style aria-hidden="true">
1207                                        <span class="device-picker-thumb-glyph">{glyph}</span>
1208                                    </span>
1209                                }
1210                            });
1211                            let badge_view = opt.badge.clone().map(|b| view! {
1212                                <span class="badge badge-plan">{b}</span>
1213                            });
1214                            view! {
1215                                <li class="device-picker-row-item" role="none">
1216                                    <button
1217                                        class=class
1218                                        role="menuitem"
1219                                        aria-pressed=opt.selected
1220                                        data-id=id_for_aria
1221                                        on:click=move |_| on_select.run(id.clone())
1222                                    >
1223                                        {thumb_view}
1224                                        <span class="device-picker-row-text">
1225                                            <span class="device-picker-row-name">{opt.name}</span>
1226                                            <span class="device-picker-row-detail">{opt.detail}</span>
1227                                        </span>
1228                                        {badge_view}
1229                                        {level_view}
1230                                        {opt.selected.then(|| view! {
1231                                            <span class="device-picker-row-check" aria-hidden="true">"✓"</span>
1232                                        })}
1233                                    </button>
1234                                </li>
1235                            }
1236                        }).collect_view()}
1237                    </ul>
1238                }.into_any(),
1239            };
1240            view! {
1241                <div class="popover-surface popover-surface-bottom-left device-picker">
1242                    {body}
1243                </div>
1244            }
1245        }}
1246    }
1247}
1248
1249/// Live, click-wired system-audio app list. All / None / Suggested
1250/// filter chips fire `on_filter`; clicking an app row fires
1251/// `on_app_toggle(bundle_id)`.
1252#[component]
1253fn LiveAudioAppList(
1254    apps: Signal<Vec<StoryAudioAppView>>,
1255    active_filter: Signal<AudioFilter>,
1256    on_filter: Callback<AudioFilter>,
1257    on_app_toggle: Callback<String>,
1258) -> impl IntoView {
1259    view! {
1260        {move || {
1261            let filter = active_filter.get();
1262            let chips: Vec<_> = [AudioFilter::All, AudioFilter::None, AudioFilter::Suggested]
1263                .iter()
1264                .map(|f| {
1265                    let f = *f;
1266                    let mut class = String::from("system-audio-filter");
1267                    if f == filter {
1268                        class.push_str(" system-audio-filter-active");
1269                    }
1270                    view! {
1271                        <button
1272                            type="button"
1273                            class=class
1274                            data-filter=f.slug()
1275                            aria-pressed=f == filter
1276                            on:click=move |_| on_filter.run(f)
1277                        >{f.label()}</button>
1278                    }
1279                })
1280                .collect();
1281            let rows: Vec<_> = apps.get().into_iter().map(|app| {
1282                let mut class = String::from("audio-app-row");
1283                if app.selected {
1284                    class.push_str(" audio-app-row-selected");
1285                }
1286                if app.live {
1287                    class.push_str(" audio-app-row-live");
1288                }
1289                let icon_style = format!("background:{}", app.icon.color);
1290                let monogram = app.icon.monogram.clone();
1291                let bundle = app.id.clone();
1292                let level_view = app.level.map(|l| view! {
1293                    <span class="audio-app-meter">
1294                        <span class="meter">
1295                            {(0_u8..8).map(|i| {
1296                                let on = f32::from(i) / 8.0 < l;
1297                                view! {
1298                                    <span class=if on { "meter-bar meter-bar-on" } else { "meter-bar" }></span>
1299                                }
1300                            }).collect_view()}
1301                        </span>
1302                    </span>
1303                });
1304                view! {
1305                    <li
1306                        class=class
1307                        role="option"
1308                        aria-selected=app.selected
1309                        on:click=move |_| on_app_toggle.run(bundle.clone())
1310                    >
1311                        <span class=if app.selected { "audio-app-check audio-app-check-on" } else { "audio-app-check" } aria-hidden="true">
1312                            {if app.selected { "✓" } else { "" }}
1313                        </span>
1314                        <span class="audio-app-icon" style=icon_style aria-hidden="true">{monogram}</span>
1315                        <span class="audio-app-text">
1316                            <span class="audio-app-name">{app.name}</span>
1317                            <span class="audio-app-context">{app.context}</span>
1318                        </span>
1319                        {app.suggested.then(|| view! {
1320                            <span class="badge badge-accent">"Suggested"</span>
1321                        })}
1322                        {app.live.then(|| view! {
1323                            <span class="audio-app-live" aria-label="Live audio">
1324                                <span class="audio-app-live-dot" aria-hidden="true"></span>
1325                                "LIVE"
1326                            </span>
1327                        })}
1328                        {level_view}
1329                    </li>
1330                }
1331            }).collect();
1332            view! {
1333                <div class="system-audio-applist">
1334                    <div class="system-audio-filters" role="toolbar" aria-label="Filter audio apps">
1335                        {chips}
1336                    </div>
1337                    <ul class="system-audio-rows" role="listbox" aria-label="Audio apps">
1338                        {rows}
1339                    </ul>
1340                </div>
1341            }
1342        }}
1343    }
1344}
1345
1346/// Live on-screen options popover with click-wired toggles. Mirrors
1347/// the storybook `OnScreenOptionsPopover` HTML so CSS stays shared.
1348#[component]
1349fn LiveOnScreenPopover(
1350    opts: Signal<Vec<OnScreenOptionView>>,
1351    on_toggle: Callback<OnScreenOptionKind>,
1352) -> impl IntoView {
1353    view! {
1354        <div class="popover-surface popover-surface-bottom-left on-screen-options-popover">
1355            <div class="popover-surface-header">
1356                <div class="popover-surface-title">"On-screen"</div>
1357                <div class="popover-surface-description">"Choose what shows during recording."</div>
1358            </div>
1359            <ul class="on-screen-options" role="group" aria-label="On-screen options">
1360                {move || opts.get().into_iter().map(|o| {
1361                    let kind = o.id;
1362                    let mut class = String::from("on-screen-option-row");
1363                    if o.disabled {
1364                        class.push_str(" on-screen-option-row-disabled");
1365                    }
1366                    let toggle_class = if o.enabled {
1367                        "toggle-switch toggle-switch-checked"
1368                    } else {
1369                        "toggle-switch"
1370                    };
1371                    view! {
1372                        <li class=class data-option=match kind {
1373                            OnScreenOptionKind::CleanDesktop => "clean-desktop",
1374                            OnScreenOptionKind::ShowKeys => "show-keys",
1375                            OnScreenOptionKind::BlurSensitiveInfo => "blur-sensitive-info",
1376                        }>
1377                            <span class="on-screen-option-toggle">
1378                                <button
1379                                    type="button"
1380                                    class=toggle_class
1381                                    role="switch"
1382                                    aria-checked=o.enabled
1383                                    disabled=o.disabled
1384                                    on:click=move |_| on_toggle.run(kind)
1385                                >
1386                                    <span class="toggle-switch-thumb" aria-hidden="true"></span>
1387                                </button>
1388                            </span>
1389                            <span class="on-screen-option-text">
1390                                <span class="on-screen-option-title">{o.title}</span>
1391                                <span class="on-screen-option-description">{o.description}</span>
1392                            </span>
1393                        </li>
1394                    }
1395                }).collect_view()}
1396            </ul>
1397        </div>
1398    }
1399}
1400
1401// ---------------------------------------------------------------------
1402// pure helpers (unit-testable, no Leptos)
1403// ---------------------------------------------------------------------
1404
1405/// Whether the recorder controls should render the "recording" state
1406/// (the RECORDING pill + Stop button). True only when the backend
1407/// reports an active session (`backend_recording`) **and** the user
1408/// hasn't just requested a stop (`stop_requested`).
1409///
1410/// The latch is what fixes the post-stop "stuck" bug: the 500 ms
1411/// backend status pump can emit a trailing `Stopping` event during
1412/// teardown that arrives after the stop handler reset the UI. Without
1413/// the latch that event flips `is_recording` back to true, and since
1414/// the backend session is then gone every further Stop click returns
1415/// "no recording session is active" without ever resetting — the
1416/// recorder is stuck until relaunch.
1417const fn show_as_recording(stop_requested: bool, backend_recording: bool) -> bool {
1418    backend_recording && !stop_requested
1419}
1420
1421/// Whether the post-record Save panel (M-SAVE.3) should replace the
1422/// record/stop footer. True while a recording is awaiting export
1423/// (`has_pending`) **or** an export just succeeded and is showing its
1424/// "Saved → Reveal" confirmation (`has_saved`, dismissed via "Done").
1425const fn save_panel_visible(has_pending: bool, has_saved: bool) -> bool {
1426    has_pending || has_saved
1427}
1428
1429fn capture_mode_slug(m: CaptureMode) -> &'static str {
1430    match m {
1431        CaptureMode::Screen => "screen",
1432        CaptureMode::Window => "window",
1433        CaptureMode::Area => "area",
1434    }
1435}
1436
1437/// Display row label (AUT-271) — the source's real name, or a generic
1438/// fallback when the platform reports an empty label. Pure + unit-tested.
1439fn display_label(d: &IpcDisplaySourceView) -> String {
1440    if d.label.trim().is_empty() {
1441        "Display".to_owned()
1442    } else {
1443        d.label.clone()
1444    }
1445}
1446
1447fn default_on_screen_options() -> Vec<OnScreenOptionView> {
1448    vec![
1449        OnScreenOptionView {
1450            id: OnScreenOptionKind::CleanDesktop,
1451            title: "Clean desktop".to_owned(),
1452            description: "Hide icons + dock during recording.".to_owned(),
1453            enabled: true,
1454            disabled: false,
1455        },
1456        OnScreenOptionView {
1457            id: OnScreenOptionKind::ShowKeys,
1458            title: "Show keys".to_owned(),
1459            description: "Render keypress badges over the recording.".to_owned(),
1460            enabled: true,
1461            disabled: false,
1462        },
1463        OnScreenOptionView {
1464            id: OnScreenOptionKind::BlurSensitiveInfo,
1465            title: "Blur sensitive".to_owned(),
1466            description: "Auto-blur regions tagged as sensitive (coming soon).".to_owned(),
1467            enabled: false,
1468            disabled: true,
1469        },
1470    ]
1471}
1472
1473fn selected_camera_label(list: &[CameraView], selected: Option<&str>) -> String {
1474    selected
1475        .and_then(|id| list.iter().find(|c| c.id == id))
1476        .or_else(|| list.first())
1477        .map_or_else(|| "No camera".to_owned(), |c| c.label.clone())
1478}
1479
1480fn camera_subtitle(list: &[CameraView], selected: Option<&str>) -> String {
1481    let count = list.len();
1482    let active = selected
1483        .and_then(|id| list.iter().find(|c| c.id == id))
1484        .or_else(|| list.first());
1485    match (active, count) {
1486        (None, _) => "Plug in a camera to capture".to_owned(),
1487        (Some(c), _) if c.is_default => "Built-in · default".to_owned(),
1488        (Some(_), 1) => "USB · 1 device".to_owned(),
1489        (Some(_), n) => format!("USB · {n} devices"),
1490    }
1491}
1492
1493fn selected_mic_label(list: &[MicrophoneView], selected: Option<&str>) -> String {
1494    selected
1495        .and_then(|id| list.iter().find(|m| m.id == id))
1496        .or_else(|| list.first())
1497        .map_or_else(|| "No microphone".to_owned(), |m| m.label.clone())
1498}
1499
1500fn mic_subtitle(list: &[MicrophoneView], selected: Option<&str>) -> String {
1501    let active = selected
1502        .and_then(|id| list.iter().find(|m| m.id == id))
1503        .or_else(|| list.first());
1504    match active {
1505        None => "Plug in a microphone to capture".to_owned(),
1506        Some(m) if m.is_default => "Built-in · default".to_owned(),
1507        Some(_) => "External device".to_owned(),
1508    }
1509}
1510
1511fn camera_device_options(list: Vec<CameraView>, selected: Option<String>) -> Vec<DeviceOptionView> {
1512    list.into_iter()
1513        .map(|c| DeviceOptionView {
1514            id: c.id.clone(),
1515            name: c.label.clone(),
1516            detail: if c.is_default {
1517                "Built-in".to_owned()
1518            } else {
1519                "External".to_owned()
1520            },
1521            badge: None,
1522            selected: selected.as_deref() == Some(c.id.as_str()),
1523            level: None,
1524            thumbnail: Some(DeviceThumb {
1525                background: "linear-gradient(135deg, #4338ca, #db2777)".to_owned(),
1526                glyph: monogram_for(&c.label),
1527            }),
1528        })
1529        .collect()
1530}
1531
1532fn mic_device_options(
1533    list: Vec<MicrophoneView>,
1534    selected: Option<String>,
1535) -> Vec<DeviceOptionView> {
1536    list.into_iter()
1537        .map(|m| DeviceOptionView {
1538            id: m.id.clone(),
1539            name: m.label.clone(),
1540            detail: if m.is_default {
1541                "Built-in".to_owned()
1542            } else {
1543                "External".to_owned()
1544            },
1545            badge: None,
1546            selected: selected.as_deref() == Some(m.id.as_str()),
1547            level: Some(0.0),
1548            thumbnail: None,
1549        })
1550        .collect()
1551}
1552
1553fn audio_app_view_list(
1554    list: Vec<IpcAudioAppView>,
1555    selected: Vec<String>,
1556) -> Vec<StoryAudioAppView> {
1557    list.into_iter()
1558        .map(|a| {
1559            let is_selected = selected.iter().any(|b| b == &a.bundle_id);
1560            let suggested = is_suggested_app(&a.bundle_id);
1561            let monogram = app_monogram(&a.display_name);
1562            let color = app_color_for(&a.bundle_id);
1563            StoryAudioAppView {
1564                id: a.bundle_id.clone(),
1565                name: a.display_name.clone(),
1566                context: if a.bundle_id.is_empty() {
1567                    "App".to_owned()
1568                } else {
1569                    a.bundle_id.clone()
1570                },
1571                selected: is_selected,
1572                suggested,
1573                live: is_selected && suggested,
1574                level: if is_selected { Some(0.4) } else { None },
1575                icon: AppIconView {
1576                    id: a.bundle_id.clone(),
1577                    monogram,
1578                    color,
1579                },
1580            }
1581        })
1582        .collect()
1583}
1584
1585fn device_state_for(perm: CameraPermission, empty: bool) -> DevicePickerState {
1586    match (perm, empty) {
1587        (CameraPermission::Denied | CameraPermission::NotDetermined, _) => {
1588            DevicePickerState::PermissionNeeded
1589        }
1590        (CameraPermission::Granted, true) => DevicePickerState::Empty,
1591        (CameraPermission::Granted, false) => DevicePickerState::Populated,
1592    }
1593}
1594
1595fn aspect_ratio_for(w: u32, h: u32) -> (u16, u16) {
1596    if w == 0 || h == 0 {
1597        return (16, 10);
1598    }
1599    let g = gcd(w, h);
1600    let nw = u16::try_from((w / g).min(u32::from(u16::MAX))).unwrap_or(u16::MAX);
1601    let nh = u16::try_from((h / g).min(u32::from(u16::MAX))).unwrap_or(u16::MAX);
1602    (nw, nh)
1603}
1604
1605const fn gcd(a: u32, b: u32) -> u32 {
1606    if b == 0 { a } else { gcd(b, a % b) }
1607}
1608
1609fn format_display_size(w: u32, h: u32) -> String {
1610    // Diagonal in inches assuming ~226 dpi (Retina). Best-effort label.
1611    let w = f64::from(w);
1612    let h = f64::from(h);
1613    let diag = w.hypot(h) / 226.0;
1614    format!("{diag:.0}\"")
1615}
1616
1617fn monogram_for(label: &str) -> String {
1618    let mut out = String::new();
1619    for word in label.split_whitespace().take(2) {
1620        if let Some(c) = word.chars().next() {
1621            out.push(c.to_ascii_uppercase());
1622        }
1623    }
1624    if out.is_empty() {
1625        out.push('?');
1626    }
1627    out
1628}
1629
1630fn app_monogram(name: &str) -> String {
1631    name.chars()
1632        .find(char::is_ascii_alphanumeric)
1633        .map_or_else(|| "·".to_owned(), |c| c.to_ascii_uppercase().to_string())
1634}
1635
1636fn app_color_for(bundle_id: &str) -> String {
1637    // Stable per-bundle hue derived from a tiny FNV-1a hash.
1638    let mut h: u32 = 2_166_136_261;
1639    for b in bundle_id.bytes() {
1640        h ^= u32::from(b);
1641        h = h.wrapping_mul(16_777_619);
1642    }
1643    let hue = h % 360;
1644    format!("hsl({hue} 70% 45%)")
1645}
1646
1647fn is_suggested_app(bundle_id: &str) -> bool {
1648    matches!(
1649        bundle_id,
1650        "com.spotify.client" | "com.google.Chrome" | "com.apple.Safari" | "com.microsoft.teams2"
1651    )
1652}
1653
1654// ---------------------------------------------------------------------
1655// IPC refreshers (spawn_local, write into RwSignals)
1656// ---------------------------------------------------------------------
1657
1658fn refresh_cameras(
1659    cameras: RwSignal<Vec<CameraView>>,
1660    selected: RwSignal<Option<String>>,
1661    permission: RwSignal<CameraPermission>,
1662) {
1663    spawn_local(async move {
1664        permission.set(camera_ipc::camera_permission_status().await);
1665        let list = camera_ipc::list_cameras().await;
1666        if selected.get().is_none()
1667            && let Some(first) = list.first()
1668        {
1669            let id = first.id.clone();
1670            selected.set(Some(id.clone()));
1671            spawn_local(async move {
1672                camera_ipc::start_preview(id).await;
1673            });
1674        }
1675        cameras.set(list);
1676    });
1677}
1678
1679fn refresh_mics(
1680    mics: RwSignal<Vec<MicrophoneView>>,
1681    selected: RwSignal<Option<String>>,
1682    permission: RwSignal<CameraPermission>,
1683) {
1684    spawn_local(async move {
1685        permission.set(mic_ipc::microphone_permission_status().await);
1686        let list = mic_ipc::list_microphones().await;
1687        if selected.get().is_none()
1688            && let Some(first) = list.first()
1689        {
1690            selected.set(Some(first.id.clone()));
1691        }
1692        mics.set(list);
1693    });
1694}
1695
1696fn refresh_displays(
1697    displays: RwSignal<Vec<IpcDisplaySourceView>>,
1698    selected: RwSignal<Option<String>>,
1699    err: RwSignal<Option<String>>,
1700) {
1701    spawn_local(async move {
1702        match screen_ipc::list_screen_displays().await {
1703            ScreenSourcesResult::Ok(list) => {
1704                if selected.get().is_none()
1705                    && let Some(primary) =
1706                        list.iter().find(|d| d.is_primary).or_else(|| list.first())
1707                {
1708                    selected.set(Some(primary.id.clone()));
1709                }
1710                displays.set(list);
1711                err.set(None);
1712            }
1713            ScreenSourcesResult::Err(msg) => err.set(Some(msg)),
1714        }
1715    });
1716}
1717
1718fn refresh_audio_apps(apps: RwSignal<Vec<IpcAudioAppView>>, err: RwSignal<Option<String>>) {
1719    spawn_local(async move {
1720        match system_audio_ipc::list_audio_apps().await {
1721            ListAudioAppsResult::Ok(list) => {
1722                apps.set(list);
1723                err.set(None);
1724            }
1725            ListAudioAppsResult::Err(msg) => err.set(Some(msg)),
1726        }
1727    });
1728}
1729
1730fn install_status_listener(status: RwSignal<RecordingStatusViewIpc>) {
1731    // Subscribe to the Tauri-side `recording-status` push event so the
1732    // page reactively reflects Running / Stopping / Idle. Without this
1733    // the Start↔Stop cycle would never flip — the button would stay at
1734    // "Start recording" and a second click would launch ANOTHER
1735    // recording instead of stopping the first.
1736    recording_ipc::install_recording_status_listener(status);
1737}
1738
1739#[cfg(test)]
1740mod tests {
1741    use super::*;
1742
1743    #[test]
1744    fn stop_latch_suppresses_trailing_recording_state() {
1745        // Normal recording: backend says recording, no stop pending.
1746        assert!(show_as_recording(false, true));
1747        // The stuck-bug guard: the user requested stop, but a trailing
1748        // `Stopping` event still reports recording — the controls must
1749        // read as idle so the pill + Stop button clear.
1750        assert!(!show_as_recording(true, true));
1751        // An idle backend is always idle, latch or not.
1752        assert!(!show_as_recording(false, false));
1753        assert!(!show_as_recording(true, false));
1754    }
1755
1756    #[test]
1757    fn save_panel_visibility_rule() {
1758        // Nothing pending, nothing saved → normal record/stop footer.
1759        assert!(!save_panel_visible(false, false));
1760        // Awaiting export → panel shows (choosing state).
1761        assert!(save_panel_visible(true, false));
1762        // Just exported → panel stays for the "Saved → Reveal" state.
1763        assert!(save_panel_visible(false, true));
1764        // Both set is defensive (shouldn't happen — export clears
1765        // pending before setting saved) but still shows the panel.
1766        assert!(save_panel_visible(true, true));
1767    }
1768
1769    #[test]
1770    fn monogram_initials() {
1771        assert_eq!(monogram_for("Spotify"), "S");
1772        assert_eq!(monogram_for("FaceTime HD Camera"), "FH");
1773        assert_eq!(monogram_for("iPhone 15 Pro"), "I1");
1774        assert_eq!(monogram_for(""), "?");
1775    }
1776
1777    #[test]
1778    fn aspect_ratio_reduces() {
1779        assert_eq!(aspect_ratio_for(3024, 1964), (756, 491));
1780        assert_eq!(aspect_ratio_for(1920, 1080), (16, 9));
1781        assert_eq!(aspect_ratio_for(0, 0), (16, 10));
1782    }
1783
1784    #[test]
1785    fn capture_mode_slugs() {
1786        assert_eq!(capture_mode_slug(CaptureMode::Screen), "screen");
1787        assert_eq!(capture_mode_slug(CaptureMode::Window), "window");
1788        assert_eq!(capture_mode_slug(CaptureMode::Area), "area");
1789    }
1790
1791    #[test]
1792    fn display_label_uses_real_name_or_falls_back() {
1793        let named = IpcDisplaySourceView {
1794            id: "display-1".to_owned(),
1795            label: "DELL U2723QE".to_owned(),
1796            width: 3840,
1797            height: 2160,
1798            is_primary: false,
1799        };
1800        assert_eq!(display_label(&named), "DELL U2723QE");
1801        // Empty / whitespace label → generic fallback (never the old hardcoded
1802        // "Built-in Retina", which mislabeled every external monitor).
1803        let blank = IpcDisplaySourceView {
1804            id: "display-2".to_owned(),
1805            label: "   ".to_owned(),
1806            width: 1920,
1807            height: 1080,
1808            is_primary: true,
1809        };
1810        assert_eq!(display_label(&blank), "Display");
1811    }
1812
1813    #[test]
1814    fn device_state_classification() {
1815        assert_eq!(
1816            device_state_for(CameraPermission::Denied, false),
1817            DevicePickerState::PermissionNeeded
1818        );
1819        assert_eq!(
1820            device_state_for(CameraPermission::Granted, true),
1821            DevicePickerState::Empty
1822        );
1823        assert_eq!(
1824            device_state_for(CameraPermission::Granted, false),
1825            DevicePickerState::Populated
1826        );
1827    }
1828
1829    #[test]
1830    fn camera_subtitle_handles_states() {
1831        assert_eq!(camera_subtitle(&[], None), "Plug in a camera to capture");
1832        let cam = CameraView {
1833            id: "id".into(),
1834            label: "Cam".into(),
1835            is_default: true,
1836        };
1837        assert_eq!(camera_subtitle(&[cam], Some("id")), "Built-in · default");
1838    }
1839
1840    #[test]
1841    fn default_on_screen_has_three() {
1842        let opts = default_on_screen_options();
1843        assert_eq!(opts.len(), 3);
1844    }
1845
1846    #[test]
1847    fn suggested_includes_spotify() {
1848        assert!(is_suggested_app("com.spotify.client"));
1849        assert!(!is_suggested_app("com.example.unknown"));
1850    }
1851}