Skip to main content

app_ui/
mic_picker.rs

1//! `<MicPicker />` — live-data dropdown selector for microphones in
2//! the Recorder surface (M-MIC.2 / AUT-279).
3//!
4//! Mirror of [`crate::camera_picker`] for the audio path. Combines
5//! the M-MIC.1 IPC helpers with the storybook `CaptureSourceRow` +
6//! `DevicePickerMenu` shapes (M-UI.8 / AUT-128) to provide:
7//!
8//! - On mount: invoke `microphone_permission_status` + `list_microphones`
9//!   to seed the picker.
10//! - On open: re-fetch so newly-plugged USB mics + paired Bluetooth
11//!   headsets appear.
12//! - On select: invoke `start_mic_capture(mic_id)` + persist the
13//!   selection to `LocalStorage` so re-opens land on the same mic.
14//! - Permission-denied state uses the same three-state contract as
15//!   the camera picker (`Granted` / `NotDetermined` / `Denied`).
16//! - Auto-start on first mount is **opt-in via a `MicLifecycle::Idle
17//!   → Starting`** guard. Unlike the camera path (which auto-starts
18//!   the preview to make the canvas alive), the mic worker only
19//!   starts on explicit user click — recording from the mic without
20//!   the user asking would be surprising even for a default mic.
21//!
22//! ```admonish note title="Per-device gst selection deferred"
23//! M-MIC.1's worker uses `autoaudiosrc`, which always opens the OS
24//! default mic regardless of the `mic_id` we send. The picker UX
25//! still works — `start_mic_capture(mic_id)` is the IPC contract —
26//! but the OS-default-only behaviour means clicking a non-default
27//! mic doesn't yet switch the active input. Per-mic wiring
28//! (`osxaudiosrc device-uid=…` etc.) is a follow-up that's a
29//! drop-in extension to the worker's pipeline args; no UI changes
30//! needed.
31//! ```
32
33use leptos::prelude::*;
34use leptos::task::spawn_local;
35use ui_storybook::components::primitives::ChevronDown;
36
37use crate::camera_ipc::CameraPermission;
38use crate::mic_ipc::{self, MicrophoneView};
39
40/// `LocalStorage` key for the "last used" mic id. Cfg-gated to wasm32
41/// to match the `read_last_used` / `write_last_used` helpers — the
42/// native target's unit tests don't have a `LocalStorage` impl.
43#[cfg(target_arch = "wasm32")]
44const LAST_USED_KEY: &str = "screen.mic.last_used_id";
45
46/// `<MicPicker />` — live mic picker rendered alongside the camera
47/// picker in the Recorder surface.
48#[component]
49pub fn MicPicker() -> impl IntoView {
50    let mics = RwSignal::new(Vec::<MicrophoneView>::new());
51    let selected_id = RwSignal::new(Option::<String>::None);
52    let permission = RwSignal::new(CameraPermission::Granted);
53    let open = RwSignal::new(false);
54    // M-AUDIO.METER / AUT-287 — live RMS level from the worker.
55    let level = RwSignal::new(0.0_f32);
56    // M-RECORD.3 — lock the trigger while a coordinated session is
57    // active.
58    let recording_lock = RwSignal::new(false);
59    crate::recording_ipc::install_recording_lock_listener(recording_lock);
60
61    // Subscribe once at component mount. The Tauri event listener
62    // leaks the closure intentionally (see subscribe_mic_level docs);
63    // the worker stops emitting when the user clicks stop_mic_capture.
64    mic_ipc::subscribe_mic_level(move |l| level.set(l));
65
66    // Initial mount: probe permission, enumerate, restore last-used
67    // selection. Note: unlike the camera picker we do NOT auto-start
68    // the worker — mic capture is opt-in (see module docs).
69    refresh_microphones(mics, selected_id, permission);
70
71    let on_select = move |id: String| {
72        write_last_used(&id);
73        let id_for_invoke = id.clone();
74        spawn_local(async move {
75            mic_ipc::start_mic_capture(id_for_invoke).await;
76        });
77        selected_id.set(Some(id));
78        open.set(false);
79    };
80
81    view! {
82        <div class="mic-picker">
83            <button
84                class="mic-picker-trigger"
85                aria-haspopup="listbox"
86                aria-expanded=move || open.get()
87                prop:disabled=move || recording_lock.get()
88                title=move || if recording_lock.get() { "Recording in progress — stop the recording to change microphones" } else { "" }
89                on:click=move |_| {
90                    if recording_lock.get() { return; }
91                    let will_open = !open.get_untracked();
92                    open.set(will_open);
93                    if will_open {
94                        refresh_microphones(mics, selected_id, permission);
95                    }
96                }
97            >
98                <span class="mic-picker-icon" aria-hidden="true">"🎙"</span>
99                <span class="mic-picker-label">
100                    {move || selected_label(&mics.get(), selected_id.get().as_ref())}
101                </span>
102                <span class="mic-picker-chevron" aria-hidden="true">
103                    <ChevronDown />
104                </span>
105            </button>
106            <div class="audio-meter" aria-label="Microphone input level">
107                <div
108                    class="audio-meter-bar"
109                    style:width=move || format!("{:.1}%", (level.get() * 100.0).clamp(0.0, 100.0))
110                ></div>
111            </div>
112            <Show when=move || open.get() fallback=|| view! { <></> }>
113                <div class="mic-picker-menu" role="listbox">
114                    <MicPickerBody
115                        mics=mics
116                        selected_id=selected_id
117                        permission=permission
118                        on_select=Callback::new(on_select)
119                    />
120                </div>
121            </Show>
122        </div>
123    }
124}
125
126#[component]
127fn MicPickerBody(
128    mics: RwSignal<Vec<MicrophoneView>>,
129    selected_id: RwSignal<Option<String>>,
130    permission: RwSignal<CameraPermission>,
131    on_select: Callback<String>,
132) -> impl IntoView {
133    move || {
134        match (permission.get(), mics.get()) {
135        (CameraPermission::Denied | CameraPermission::NotDetermined, _) => view! {
136            <div class="mic-picker-state mic-picker-state--permission">
137                <p>{"Microphone access required."}</p>
138                <p class="mic-picker-state-help">
139                    {"Grant access in System Settings → Privacy & Security, then re-open this picker."}
140                </p>
141                <button
142                    type="button"
143                    class="mic-picker-state-button"
144                    on:click=move |_| {
145                        spawn_local(async move {
146                            mic_ipc::open_settings_microphone().await;
147                        });
148                    }
149                >
150                    {"Open System Settings → Microphone"}
151                </button>
152            </div>
153        }
154        .into_any(),
155        (CameraPermission::Granted, list) if list.is_empty() => view! {
156            <div class="mic-picker-state mic-picker-state--empty">
157                <p>{"No microphones detected."}</p>
158                <p class="mic-picker-state-help">
159                    {"Plug in a USB mic or pair a Bluetooth headset, then re-open this picker."}
160                </p>
161            </div>
162        }
163        .into_any(),
164        (CameraPermission::Granted, list) => view! {
165            <ul class="mic-picker-list" role="none">
166                {list
167                    .into_iter()
168                    .map(|mic| render_row(mic, selected_id.get(), on_select))
169                    .collect_view()}
170            </ul>
171        }
172        .into_any(),
173    }
174    }
175}
176
177fn refresh_microphones(
178    mics: RwSignal<Vec<MicrophoneView>>,
179    selected_id: RwSignal<Option<String>>,
180    permission: RwSignal<CameraPermission>,
181) {
182    spawn_local(async move {
183        let perm = mic_ipc::microphone_permission_status().await;
184        permission.set(perm);
185        let list = mic_ipc::list_microphones().await;
186        let default_id = selected_id
187            .get_untracked()
188            .filter(|id| list.iter().any(|mic| mic.id == *id))
189            .or_else(|| resolve_default(&list));
190        selected_id.set(default_id);
191        mics.set(list);
192    });
193}
194
195fn render_row(
196    mic: MicrophoneView,
197    selected: Option<String>,
198    on_select: Callback<String>,
199) -> impl IntoView {
200    let is_selected = selected.as_deref() == Some(mic.id.as_str());
201    let mut class = String::from("mic-picker-row");
202    if is_selected {
203        class.push_str(" mic-picker-row-selected");
204    }
205    let id_for_click = mic.id.clone();
206    let id_aria = mic.id.clone();
207    let label = mic.label.clone();
208    let sub = format_subline(&mic);
209    view! {
210        <li>
211            <button
212                class=class
213                role="option"
214                aria-selected=is_selected
215                data-mic-id=id_aria
216                on:click=move |_| on_select.run(id_for_click.clone())
217            >
218                <span class="mic-picker-row-label">{label}</span>
219                <span class="mic-picker-row-sub">{sub}</span>
220                {is_selected.then(|| view! {
221                    <span class="mic-picker-row-check" aria-hidden="true">"✓"</span>
222                })}
223            </button>
224        </li>
225    }
226}
227
228/// Build the per-row "48 kHz · stereo · default" subline. `0` values
229/// for `channels` / `sample_rate_hz` (the "unknown" sentinel from
230/// M-MIC.0) are omitted entirely rather than rendered as
231/// "0 Hz / 0ch" noise.
232fn format_subline(mic: &MicrophoneView) -> String {
233    let mut parts: Vec<String> = Vec::new();
234    if mic.sample_rate_hz > 0 {
235        parts.push(format_sample_rate(mic.sample_rate_hz));
236    }
237    if mic.channels > 0 {
238        parts.push(format_channels(mic.channels).to_string());
239    }
240    if mic.is_default {
241        parts.push("default".to_string());
242    }
243    parts.join(" · ")
244}
245
246fn format_sample_rate(rate: u32) -> String {
247    if rate.is_multiple_of(1_000) {
248        format!("{} kHz", rate / 1_000)
249    } else {
250        format!("{rate} Hz")
251    }
252}
253
254fn format_channels(channels: u8) -> &'static str {
255    match channels {
256        1 => "mono",
257        2 => "stereo",
258        _ => "multi-ch",
259    }
260}
261
262fn selected_label(mics: &[MicrophoneView], selected_id: Option<&String>) -> String {
263    let Some(id) = selected_id else {
264        return "Select microphone".into();
265    };
266    mics.iter()
267        .find(|m| m.id == *id)
268        .map_or_else(|| "Select microphone".to_string(), |m| m.label.clone())
269}
270
271/// Resolve which mic should be selected on initial mount:
272/// 1. Persisted "last used" id (if present + still in the device list).
273/// 2. The `is_default = true` device.
274/// 3. The first device.
275/// 4. `None` if no devices.
276fn resolve_default(mics: &[MicrophoneView]) -> Option<String> {
277    if mics.is_empty() {
278        return None;
279    }
280    if let Some(last) = read_last_used()
281        && mics.iter().any(|m| m.id == last)
282    {
283        return Some(last);
284    }
285    mics.iter()
286        .find(|m| m.is_default)
287        .map(|m| m.id.clone())
288        .or_else(|| mics.first().map(|m| m.id.clone()))
289}
290
291#[cfg(target_arch = "wasm32")]
292fn read_last_used() -> Option<String> {
293    let storage = web_sys::window()?.local_storage().ok().flatten()?;
294    storage.get_item(LAST_USED_KEY).ok().flatten()
295}
296
297#[cfg(not(target_arch = "wasm32"))]
298fn read_last_used() -> Option<String> {
299    None
300}
301
302#[cfg(target_arch = "wasm32")]
303fn write_last_used(id: &str) {
304    let Some(window) = web_sys::window() else {
305        return;
306    };
307    let Ok(Some(storage)) = window.local_storage() else {
308        return;
309    };
310    let _ = storage.set_item(LAST_USED_KEY, id);
311}
312
313#[cfg(not(target_arch = "wasm32"))]
314fn write_last_used(_id: &str) {}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn mic(id: &str, label: &str, is_default: bool) -> MicrophoneView {
321        MicrophoneView {
322            id: id.into(),
323            label: label.into(),
324            is_default,
325            channels: 2,
326            sample_rate_hz: 48_000,
327            native_id: String::new(),
328        }
329    }
330
331    #[test]
332    fn resolve_default_returns_none_for_empty() {
333        assert_eq!(resolve_default(&[]), None);
334    }
335
336    #[test]
337    fn resolve_default_picks_is_default_when_no_persisted() {
338        let list = vec![
339            mic("a", "Mic A", false),
340            mic("b", "Mic B", true),
341            mic("c", "Mic C", false),
342        ];
343        // LocalStorage doesn't exist in native unit tests, so we
344        // fall through to the is_default branch.
345        assert_eq!(resolve_default(&list), Some("b".into()));
346    }
347
348    #[test]
349    fn resolve_default_falls_back_to_first_when_none_default() {
350        let list = vec![mic("a", "Mic A", false), mic("b", "Mic B", false)];
351        assert_eq!(resolve_default(&list), Some("a".into()));
352    }
353
354    #[test]
355    fn selected_label_returns_placeholder_when_unselected() {
356        assert_eq!(selected_label(&[], None), "Select microphone");
357    }
358
359    #[test]
360    fn selected_label_returns_mic_label_when_selected() {
361        let list = vec![mic("a", "MacBook Pro Microphone", true)];
362        let id = "a".to_string();
363        assert_eq!(selected_label(&list, Some(&id)), "MacBook Pro Microphone");
364    }
365
366    #[test]
367    fn format_subline_omits_zero_unknowns() {
368        let mut m = mic("a", "Weird Mic", false);
369        m.channels = 0;
370        m.sample_rate_hz = 0;
371        assert_eq!(format_subline(&m), "");
372        m.is_default = true;
373        assert_eq!(format_subline(&m), "default");
374    }
375
376    #[test]
377    fn format_subline_full() {
378        let m = mic("a", "Mic", true);
379        assert_eq!(format_subline(&m), "48 kHz · stereo · default");
380    }
381
382    #[test]
383    fn format_subline_mono_default_off() {
384        let mut m = mic("a", "Mic", false);
385        m.channels = 1;
386        assert_eq!(format_subline(&m), "48 kHz · mono");
387    }
388
389    #[test]
390    fn format_sample_rate_uses_kilohertz_when_multiple_of_1000() {
391        assert_eq!(format_sample_rate(48_000), "48 kHz");
392        assert_eq!(format_sample_rate(44_100), "44100 Hz");
393        assert_eq!(format_sample_rate(16_000), "16 kHz");
394    }
395}