1use 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
48struct DisplaySummary {
50 name: String,
51 size_label: String,
52 dimensions_label: String,
53}
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
58enum OpenPicker {
59 #[default]
61 None,
62 Display,
64 Camera,
66 Microphone,
68 SystemAudio,
70 OnScreen,
72}
73
74#[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 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 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 let stop_requested = RwSignal::new(false);
122
123 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 let overflow_open = RwSignal::new(false);
135
136 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 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 crate::bubble_ipc::set_webcam_bubble_visibility(camera_enabled.get());
160
161 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 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 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 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 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 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 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 stop_requested.set(true);
470 spawn_local(async move {
471 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 stop_requested.set(false);
486 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 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 Err(err) => error_msg.set(Some(err)),
530 }
531 export_busy.set(false);
532 });
533 });
534
535 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 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! {
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 <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 <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 <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 <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 {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#[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#[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#[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#[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#[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#[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
1401const fn show_as_recording(stop_requested: bool, backend_recording: bool) -> bool {
1418 backend_recording && !stop_requested
1419}
1420
1421const 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
1437fn 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 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 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
1654fn 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 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 assert!(show_as_recording(false, true));
1747 assert!(!show_as_recording(true, true));
1751 assert!(!show_as_recording(false, false));
1753 assert!(!show_as_recording(true, false));
1754 }
1755
1756 #[test]
1757 fn save_panel_visibility_rule() {
1758 assert!(!save_panel_visible(false, false));
1760 assert!(save_panel_visible(true, false));
1762 assert!(save_panel_visible(false, true));
1764 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 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}