1use leptos::prelude::*;
22use leptos::task::spawn_local;
23use ui_storybook::components::primitives::ChevronDown;
24
25use crate::system_audio_ipc::{self, AudioAppFilterView, AudioAppView, ListAudioAppsResult};
26
27#[cfg(target_arch = "wasm32")]
29const SELECTED_KEY: &str = "screen.system_audio.selected_bundle_ids";
30
31#[cfg(target_arch = "wasm32")]
33const ENABLED_KEY: &str = "screen.system_audio.enabled";
34
35#[cfg(target_arch = "wasm32")]
43const FILTER_DEBOUNCE_MS: u32 = 250;
44
45const SUGGESTED_BUNDLE_PREFIXES: &[&str] = &[
55 "com.google.Chrome",
57 "com.apple.Safari",
58 "org.mozilla.firefox",
59 "com.brave.Browser",
60 "company.thebrowser.Browser", "com.microsoft.edgemac",
62 "com.spotify.client",
64 "com.apple.Music",
65 "com.apple.TV",
66 "tv.plex.player",
67 "com.netflix.Netflix",
68 "com.tinyspeck.slackmacgap",
70 "us.zoom.xos",
71 "com.microsoft.teams2",
72 "com.hnc.Discord",
73 "com.google.meet",
74];
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum ActiveChip {
83 All,
85 None,
87 Suggested,
89 Custom,
91}
92
93fn compute_active_chip(
98 enabled: bool,
99 selected: &[String],
100 all_apps: &[AudioAppView],
101) -> ActiveChip {
102 if !enabled {
103 return ActiveChip::None;
104 }
105 if selected.is_empty() {
106 return ActiveChip::All;
107 }
108 let suggested = suggested_bundle_ids(all_apps);
109 if same_set(selected, &suggested) {
110 ActiveChip::Suggested
111 } else {
112 ActiveChip::Custom
113 }
114}
115
116fn same_set(a: &[String], b: &[String]) -> bool {
118 if a.len() != b.len() {
119 return false;
120 }
121 let set: std::collections::HashSet<&str> = a.iter().map(String::as_str).collect();
122 b.iter().all(|s| set.contains(s.as_str()))
123}
124
125fn suggested_bundle_ids(apps: &[AudioAppView]) -> Vec<String> {
128 apps.iter()
129 .filter(|a| {
130 SUGGESTED_BUNDLE_PREFIXES
131 .iter()
132 .any(|p| a.bundle_id.starts_with(p))
133 })
134 .map(|a| a.bundle_id.clone())
135 .collect()
136}
137
138#[allow(
141 clippy::too_many_lines,
142 reason = "Leptos #[component] body is signals + closures + view!; splitting at the natural seam (each signal's click handler) would only hurt readability without reducing complexity."
143)]
144#[component]
145pub fn SystemAudioPicker() -> impl IntoView {
146 let enabled = RwSignal::new(read_enabled());
147 let expanded = RwSignal::new(false);
148 let apps = RwSignal::new(Vec::<AudioAppView>::new());
149 let error_message = RwSignal::new(Option::<String>::None);
150 let selected_ids = RwSignal::new(read_selected_ids());
151 let level = RwSignal::new(0.0_f32);
154 let recording_lock = RwSignal::new(false);
157 crate::recording_ipc::install_recording_lock_listener(recording_lock);
158
159 system_audio_ipc::subscribe_system_audio_level(move |l| level.set(l));
160
161 let on_toggle_enabled = move |_| {
163 let next = !enabled.get();
164 enabled.set(next);
165 write_enabled(next);
166 let selected_now = selected_ids.get();
167 spawn_local(async move {
168 if next {
169 match system_audio_ipc::start_system_audio_capture().await {
170 Ok(()) => {
171 error_message.set(None);
172 apply_filter_from_selection(&selected_now).await;
173 }
174 Err(err) => {
175 enabled.set(false);
177 write_enabled(false);
178 error_message.set(Some(err));
179 }
180 }
181 } else {
182 system_audio_ipc::stop_system_audio_capture().await;
183 }
184 });
185 };
186
187 let on_toggle_expand = move |_| {
189 let next = !expanded.get();
190 expanded.set(next);
191 if next {
192 spawn_local(async move {
193 match system_audio_ipc::list_audio_apps().await {
194 ListAudioAppsResult::Ok(list) => {
195 error_message.set(None);
196 apps.set(list);
197 }
198 ListAudioAppsResult::Err(msg) => {
199 error_message.set(Some(msg));
200 apps.set(Vec::new());
201 }
202 }
203 });
204 }
205 };
206
207 let on_toggle_app = move |bundle_id: String| {
208 selected_ids.update(|ids| {
209 if let Some(pos) = ids.iter().position(|id| id == &bundle_id) {
210 ids.remove(pos);
211 } else {
212 ids.push(bundle_id);
213 }
214 });
215 let selected_now = selected_ids.get();
216 write_selected_ids(&selected_now);
217 if enabled.get() {
218 schedule_filter_apply(selected_now);
219 }
220 };
221
222 let on_chip_all = move |_| {
227 selected_ids.set(Vec::new());
228 write_selected_ids(&[]);
229 if enabled.get() {
230 schedule_filter_apply(Vec::new());
231 } else {
232 enabled.set(true);
233 write_enabled(true);
234 spawn_local(async move {
235 match system_audio_ipc::start_system_audio_capture().await {
236 Ok(()) => {
237 error_message.set(None);
238 schedule_filter_apply(Vec::new());
239 }
240 Err(err) => {
241 enabled.set(false);
242 write_enabled(false);
243 error_message.set(Some(err));
244 }
245 }
246 });
247 }
248 };
249 let on_chip_none = move |_| {
250 enabled.set(false);
251 write_enabled(false);
252 spawn_local(async move {
253 system_audio_ipc::stop_system_audio_capture().await;
254 });
255 };
256 let on_chip_suggested = move |_| {
257 let pick = suggested_bundle_ids(&apps.get());
258 selected_ids.set(pick.clone());
259 write_selected_ids(&pick);
260 if enabled.get() {
261 schedule_filter_apply(pick);
262 } else {
263 enabled.set(true);
264 write_enabled(true);
265 let pick_for_start = pick.clone();
266 spawn_local(async move {
267 match system_audio_ipc::start_system_audio_capture().await {
268 Ok(()) => {
269 error_message.set(None);
270 schedule_filter_apply(pick_for_start);
271 }
272 Err(err) => {
273 enabled.set(false);
274 write_enabled(false);
275 error_message.set(Some(err));
276 }
277 }
278 });
279 }
280 };
281
282 view! {
283 <div class="system-audio-picker">
284 <div class="system-audio-picker-header">
285 <button
286 type="button"
287 class="system-audio-picker-toggle"
288 role="switch"
289 aria-checked=move || enabled.get()
290 data-enabled=move || if enabled.get() { "true" } else { "false" }
291 prop:disabled=move || recording_lock.get()
292 title=move || if recording_lock.get() { "Recording in progress — stop the recording to toggle system audio" } else { "" }
293 on:click=move |evt| {
294 if recording_lock.get() { return; }
295 on_toggle_enabled(evt);
296 }
297 >
298 <span class="system-audio-picker-icon" aria-hidden="true">"🔈"</span>
299 <span class="system-audio-picker-label">"System audio"</span>
300 <span class="system-audio-picker-state">
301 {move || if enabled.get() { "On" } else { "Off" }}
302 </span>
303 </button>
304 <button
305 type="button"
306 class="system-audio-picker-expand"
307 aria-haspopup="listbox"
308 aria-expanded=move || expanded.get()
309 on:click=on_toggle_expand
310 >
311 <span class="system-audio-picker-summary">
312 {move || summary_label(selected_ids.get().len())}
313 </span>
314 <span class="system-audio-picker-chevron" aria-hidden="true">
315 <ChevronDown />
316 </span>
317 </button>
318 </div>
319 <div class="audio-meter" aria-label="System audio output level">
320 <div
321 class="audio-meter-bar"
322 style:width=move || format!("{:.1}%", (level.get() * 100.0).clamp(0.0, 100.0))
323 ></div>
324 </div>
325 <Show when=move || expanded.get() fallback=|| view! { <></> }>
326 <div class="system-audio-picker-menu" role="listbox">
327 <div class="system-audio-picker-chips">
328 {
329 let chip_signal = Memo::new(move |_| {
330 compute_active_chip(enabled.get(), &selected_ids.get(), &apps.get())
331 });
332 view! {
333 <button
334 type="button"
335 class="system-audio-picker-chip"
336 data-active=move || (chip_signal.get() == ActiveChip::All).to_string()
337 on:click=on_chip_all
338 >"All"</button>
339 <button
340 type="button"
341 class="system-audio-picker-chip"
342 data-active=move || (chip_signal.get() == ActiveChip::None).to_string()
343 on:click=on_chip_none
344 >"None"</button>
345 <button
346 type="button"
347 class="system-audio-picker-chip"
348 data-active=move || (chip_signal.get() == ActiveChip::Suggested).to_string()
349 on:click=on_chip_suggested
350 >"Suggested"</button>
351 <button
352 type="button"
353 class="system-audio-picker-chip"
354 data-active=move || (chip_signal.get() == ActiveChip::Custom).to_string()
355 disabled=true
356 >"Custom"</button>
357 }
358 }
359 </div>
360 <SystemAudioBody
361 apps=apps
362 selected_ids=selected_ids
363 error_message=error_message
364 on_toggle_app=Callback::new(on_toggle_app)
365 />
366 </div>
367 </Show>
368 </div>
369 }
370}
371
372#[component]
373fn SystemAudioBody(
374 apps: RwSignal<Vec<AudioAppView>>,
375 selected_ids: RwSignal<Vec<String>>,
376 error_message: RwSignal<Option<String>>,
377 on_toggle_app: Callback<String>,
378) -> impl IntoView {
379 move || {
380 match (error_message.get(), apps.get()) {
381 (Some(msg), _) => view! {
382 <div class="system-audio-picker-state-msg system-audio-picker-state-msg--error">
383 <p>{"Couldn't list apps."}</p>
384 <p class="system-audio-picker-state-help">{msg}</p>
385 <p class="system-audio-picker-state-help">
386 {"Request access first; macOS will then add this app to System Settings. After enabling it, quit and reopen the app."}
387 </p>
388 <button
389 type="button"
390 class="system-audio-picker-state-button"
391 on:click=move |_| {
392 spawn_local(async move {
393 system_audio_ipc::request_screen_recording_permission().await;
394 system_audio_ipc::open_settings_screen_recording().await;
395 });
396 }
397 >
398 {"Request Screen Recording Access"}
399 </button>
400 </div>
401 }
402 .into_any(),
403 (None, list) if list.is_empty() => view! {
404 <div class="system-audio-picker-state-msg">
405 <p>{"No running apps detected."}</p>
406 </div>
407 }
408 .into_any(),
409 (None, list) => view! {
410 <ul class="system-audio-picker-list" role="none">
411 {list
412 .into_iter()
413 .map(|app| render_app_row(app, selected_ids.get(), on_toggle_app))
414 .collect_view()}
415 </ul>
416 }
417 .into_any(),
418 }
419 }
420}
421
422fn base64_encode(input: &[u8]) -> String {
426 const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
427 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
428 for chunk in input.chunks(3) {
429 let b0 = u32::from(chunk[0]);
430 let b1 = chunk.get(1).copied().map_or(0, u32::from);
431 let b2 = chunk.get(2).copied().map_or(0, u32::from);
432 let n = (b0 << 16) | (b1 << 8) | b2;
433 let idx = |shift: u32| usize::try_from((n >> shift) & 63).unwrap_or(0);
434 out.push(char::from(A[idx(18)]));
435 out.push(char::from(A[idx(12)]));
436 out.push(if chunk.len() > 1 {
437 char::from(A[idx(6)])
438 } else {
439 '='
440 });
441 out.push(if chunk.len() > 2 {
442 char::from(A[idx(0)])
443 } else {
444 '='
445 });
446 }
447 out
448}
449
450fn icon_data_url(png_bytes: &[u8]) -> Option<String> {
454 if png_bytes.is_empty() {
455 return None;
456 }
457 Some(format!(
458 "data:image/png;base64,{}",
459 base64_encode(png_bytes)
460 ))
461}
462
463fn render_app_row(
464 app: AudioAppView,
465 selected: Vec<String>,
466 on_toggle_app: Callback<String>,
467) -> impl IntoView {
468 let is_selected = selected.iter().any(|id| id == &app.bundle_id);
469 let mut class = String::from("system-audio-picker-row");
470 if is_selected {
471 class.push_str(" system-audio-picker-row-selected");
472 }
473 let bundle_for_click = app.bundle_id.clone();
474 let bundle_for_attr = app.bundle_id.clone();
475 let bundle_for_caption = app.bundle_id.clone();
476 let name = app.display_name.clone();
477 let icon = match icon_data_url(&app.icon_png_bytes) {
480 Some(url) => view! {
481 <img class="system-audio-picker-row-img" src=url alt="" />
482 }
483 .into_any(),
484 None => "·".into_any(),
485 };
486 view! {
487 <li>
488 <button
489 type="button"
490 class=class
491 role="option"
492 aria-selected=is_selected
493 data-bundle-id=bundle_for_attr
494 on:click=move |_| on_toggle_app.run(bundle_for_click.clone())
495 >
496 <span class="system-audio-picker-row-icon" aria-hidden="true">
497 {icon}
498 </span>
499 <span class="system-audio-picker-row-label">{name}</span>
500 <span class="system-audio-picker-row-bundle">{bundle_for_caption}</span>
501 {is_selected.then(|| view! {
502 <span class="system-audio-picker-row-check" aria-hidden="true">"✓"</span>
503 })}
504 </button>
505 </li>
506 }
507}
508
509async fn apply_filter_from_selection(selected_ids: &[String]) {
513 let filter = if selected_ids.is_empty() {
514 AudioAppFilterView::AllAudio
515 } else {
516 AudioAppFilterView::OnlyApps(selected_ids.to_vec())
517 };
518 let _ = system_audio_ipc::set_system_audio_filter(filter).await;
519}
520
521#[cfg(target_arch = "wasm32")]
527fn schedule_filter_apply(selected_ids: Vec<String>) {
528 use gloo_timers::callback::Timeout;
529 use std::cell::RefCell;
530 thread_local! {
531 static PENDING: RefCell<Option<Timeout>> = const { RefCell::new(None) };
532 }
533 let timeout = Timeout::new(FILTER_DEBOUNCE_MS, move || {
534 let ids = selected_ids.clone();
535 leptos::task::spawn_local(async move {
536 apply_filter_from_selection(&ids).await;
537 });
538 });
539 PENDING.with(|cell| {
540 *cell.borrow_mut() = Some(timeout);
542 });
543}
544
545#[cfg(not(target_arch = "wasm32"))]
546fn schedule_filter_apply(_selected_ids: Vec<String>) {
547 }
551
552fn summary_label(count: usize) -> String {
553 match count {
554 0 => "All apps".into(),
555 1 => "1 app".into(),
556 n => format!("{n} apps"),
557 }
558}
559
560#[cfg(target_arch = "wasm32")]
561fn read_enabled() -> bool {
562 let Some(window) = web_sys::window() else {
563 return false;
564 };
565 let Ok(Some(storage)) = window.local_storage() else {
566 return false;
567 };
568 matches!(
569 storage.get_item(ENABLED_KEY).ok().flatten().as_deref(),
570 Some("true")
571 )
572}
573
574#[cfg(not(target_arch = "wasm32"))]
575fn read_enabled() -> bool {
576 false
577}
578
579#[cfg(target_arch = "wasm32")]
580fn write_enabled(value: bool) {
581 let Some(window) = web_sys::window() else {
582 return;
583 };
584 let Ok(Some(storage)) = window.local_storage() else {
585 return;
586 };
587 let _ = storage.set_item(ENABLED_KEY, if value { "true" } else { "false" });
588}
589
590#[cfg(not(target_arch = "wasm32"))]
591fn write_enabled(_value: bool) {}
592
593#[cfg(target_arch = "wasm32")]
594fn read_selected_ids() -> Vec<String> {
595 let Some(window) = web_sys::window() else {
596 return Vec::new();
597 };
598 let Ok(Some(storage)) = window.local_storage() else {
599 return Vec::new();
600 };
601 storage
602 .get_item(SELECTED_KEY)
603 .ok()
604 .flatten()
605 .and_then(|raw| serde_json::from_str(&raw).ok())
606 .unwrap_or_default()
607}
608
609#[cfg(not(target_arch = "wasm32"))]
610fn read_selected_ids() -> Vec<String> {
611 Vec::new()
612}
613
614#[cfg(target_arch = "wasm32")]
615fn write_selected_ids(ids: &[String]) {
616 let Some(window) = web_sys::window() else {
617 return;
618 };
619 let Ok(Some(storage)) = window.local_storage() else {
620 return;
621 };
622 if let Ok(json) = serde_json::to_string(ids) {
623 let _ = storage.set_item(SELECTED_KEY, &json);
624 }
625}
626
627#[cfg(not(target_arch = "wasm32"))]
628fn write_selected_ids(_ids: &[String]) {}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn base64_encode_matches_rfc4648_vectors() {
636 assert_eq!(base64_encode(b""), "");
637 assert_eq!(base64_encode(b"f"), "Zg==");
638 assert_eq!(base64_encode(b"fo"), "Zm8=");
639 assert_eq!(base64_encode(b"foo"), "Zm9v");
640 assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
641 assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
642 assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
643 }
644
645 #[test]
646 fn icon_data_url_wraps_png_bytes_or_is_none() {
647 assert_eq!(icon_data_url(&[]), None);
649 let url = icon_data_url(&[0x89, 0x50, 0x4e, 0x47]).expect("non-empty → Some");
651 assert_eq!(url, "data:image/png;base64,iVBORw==");
652 assert!(url.starts_with("data:image/png;base64,"));
653 }
654
655 #[test]
656 fn summary_label_zero_one_many() {
657 assert_eq!(summary_label(0), "All apps");
658 assert_eq!(summary_label(1), "1 app");
659 assert_eq!(summary_label(5), "5 apps");
660 }
661
662 #[test]
663 fn empty_selection_yields_all_audio_filter() {
664 let selected: Vec<String> = Vec::new();
668 let filter = if selected.is_empty() {
669 AudioAppFilterView::AllAudio
670 } else {
671 AudioAppFilterView::OnlyApps(selected)
672 };
673 assert_eq!(filter, AudioAppFilterView::AllAudio);
674 }
675
676 #[test]
677 fn non_empty_selection_yields_only_apps_filter() {
678 let selected = vec!["com.spotify.client".to_string()];
679 let filter = if selected.is_empty() {
680 AudioAppFilterView::AllAudio
681 } else {
682 AudioAppFilterView::OnlyApps(selected.clone())
683 };
684 assert_eq!(filter, AudioAppFilterView::OnlyApps(selected));
685 }
686
687 fn app(bundle: &str) -> AudioAppView {
688 AudioAppView {
689 pid: 0,
690 bundle_id: bundle.to_string(),
691 display_name: bundle.to_string(),
692 icon_png_bytes: Vec::new(),
693 }
694 }
695
696 #[test]
697 fn suggested_picks_browsers_media_comm() {
698 let running = vec![
699 app("com.google.Chrome"),
700 app("com.spotify.client"),
701 app("us.zoom.xos"),
702 app("com.apple.Notes"), app("com.apple.dock"), ];
705 let picked = suggested_bundle_ids(&running);
706 assert_eq!(picked.len(), 3);
707 assert!(picked.contains(&"com.google.Chrome".to_string()));
708 assert!(picked.contains(&"com.spotify.client".to_string()));
709 assert!(picked.contains(&"us.zoom.xos".to_string()));
710 assert!(!picked.contains(&"com.apple.Notes".to_string()));
711 }
712
713 #[test]
714 fn suggested_prefix_match_catches_versioned_bundles() {
715 let running = vec![app("com.google.Chrome.beta"), app("com.brave.Browser.dev")];
716 let picked = suggested_bundle_ids(&running);
717 assert_eq!(picked.len(), 2, "versioned bundles should match by prefix");
718 }
719
720 #[test]
721 fn active_chip_none_when_disabled() {
722 let chip = compute_active_chip(false, &[], &[]);
723 assert_eq!(chip, ActiveChip::None);
724 }
725
726 #[test]
727 fn active_chip_all_when_enabled_with_empty_selection() {
728 let chip = compute_active_chip(true, &[], &[]);
729 assert_eq!(chip, ActiveChip::All);
730 }
731
732 #[test]
733 fn active_chip_suggested_when_selection_matches_heuristic() {
734 let running = vec![app("com.spotify.client"), app("com.apple.Notes")];
735 let selected = vec!["com.spotify.client".to_string()];
736 assert_eq!(
737 compute_active_chip(true, &selected, &running),
738 ActiveChip::Suggested
739 );
740 }
741
742 #[test]
743 fn active_chip_custom_when_selection_doesnt_match_heuristic() {
744 let running = vec![app("com.spotify.client"), app("com.apple.Notes")];
745 let selected = vec!["com.apple.Notes".to_string()];
746 assert_eq!(
747 compute_active_chip(true, &selected, &running),
748 ActiveChip::Custom
749 );
750 }
751
752 #[test]
753 fn same_set_is_order_insensitive() {
754 let a = vec!["a".to_string(), "b".to_string()];
755 let b = vec!["b".to_string(), "a".to_string()];
756 assert!(same_set(&a, &b));
757 let c = vec!["a".to_string()];
758 assert!(!same_set(&a, &c));
759 }
760}