1use 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#[cfg(target_arch = "wasm32")]
44const LAST_USED_KEY: &str = "screen.mic.last_used_id";
45
46#[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 let level = RwSignal::new(0.0_f32);
56 let recording_lock = RwSignal::new(false);
59 crate::recording_ipc::install_recording_lock_listener(recording_lock);
60
61 mic_ipc::subscribe_mic_level(move |l| level.set(l));
65
66 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
228fn 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
271fn 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 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}