1use leptos::prelude::*;
23use leptos::task::spawn_local;
24use ui_storybook::components::primitives::ChevronDown;
25
26use crate::camera_ipc::{self, CameraPermission, CameraView};
27
28#[cfg(target_arch = "wasm32")]
33const LAST_USED_KEY: &str = "screen.camera.last_used_id";
34
35#[component]
38pub fn CameraPicker() -> impl IntoView {
39 let cameras = RwSignal::new(Vec::<CameraView>::new());
40 let selected_id = RwSignal::new(Option::<String>::None);
41 let permission = RwSignal::new(CameraPermission::Granted);
42 let open = RwSignal::new(false);
43 let recording_lock = RwSignal::new(false);
47 crate::recording_ipc::install_recording_lock_listener(recording_lock);
48
49 refresh_cameras(cameras, selected_id, permission, true);
50
51 let on_select = move |id: String| {
52 write_last_used(&id);
54 let id_for_invoke = id.clone();
55 spawn_local(async move {
56 camera_ipc::start_preview(id_for_invoke).await;
57 });
58 selected_id.set(Some(id));
59 open.set(false);
60 };
61
62 view! {
63 <div class="camera-picker">
64 <button
65 class="camera-picker-trigger"
66 aria-haspopup="listbox"
67 aria-expanded=move || open.get()
68 prop:disabled=move || recording_lock.get()
69 title=move || if recording_lock.get() { "Recording in progress — stop the recording to change cameras" } else { "" }
70 on:click=move |_| {
71 if recording_lock.get() { return; }
72 let will_open = !open.get_untracked();
73 open.set(will_open);
74 if will_open {
75 refresh_cameras(cameras, selected_id, permission, false);
76 }
77 }
78 >
79 <span class="camera-picker-label">
80 {move || selected_label(&cameras.get(), selected_id.get().as_ref())}
81 </span>
82 <span class="camera-picker-chevron" aria-hidden="true">
83 <ChevronDown />
84 </span>
85 </button>
86 <Show when=move || open.get() fallback=|| view! { <></> }>
87 <div class="camera-picker-menu" role="listbox">
88 <CameraPickerBody
89 cameras=cameras
90 selected_id=selected_id
91 permission=permission
92 on_select=Callback::new(on_select)
93 />
94 </div>
95 </Show>
96 </div>
97 }
98}
99
100#[component]
101fn CameraPickerBody(
102 cameras: RwSignal<Vec<CameraView>>,
103 selected_id: RwSignal<Option<String>>,
104 permission: RwSignal<CameraPermission>,
105 on_select: Callback<String>,
106) -> impl IntoView {
107 move || {
108 match (permission.get(), cameras.get()) {
109 (CameraPermission::Denied | CameraPermission::NotDetermined, _) => view! {
110 <div class="camera-picker-state camera-picker-state--permission">
111 <p>{"Camera access required."}</p>
112 <p class="camera-picker-state-help">
113 {"Grant access in System Settings → Privacy & Security, then re-open this picker."}
114 </p>
115 <button
116 type="button"
117 class="camera-picker-state-button"
118 on:click=move |_| {
119 spawn_local(async move {
120 camera_ipc::open_settings_camera().await;
121 });
122 }
123 >
124 {"Open System Settings → Camera"}
125 </button>
126 </div>
127 }
128 .into_any(),
129 (CameraPermission::Granted, list) if list.is_empty() => view! {
130 <div class="camera-picker-state camera-picker-state--empty">
131 <p>{"No cameras detected."}</p>
132 <p class="camera-picker-state-help">
133 {"On first launch the macOS permission prompts may not have fired yet. Click below to grant access to camera + microphone + screen recording in one go, then re-open this picker."}
134 </p>
135 <button
136 type="button"
137 class="camera-picker-state-button"
138 on:click=move |_| {
139 spawn_local(async move {
140 let result = camera_ipc::request_all_permissions().await;
141 log_to_console(&format!(
142 "request_all_permissions returned: camera={:?}, mic={:?}, screen={:?}",
143 result.camera, result.microphone, result.screen_recording
144 ));
145 permission.set(result.camera);
147 let list = camera_ipc::list_cameras().await;
148 cameras.set(list);
149 });
150 }
151 >
152 {"Grant access (camera, mic, screen recording)"}
153 </button>
154 <p class="camera-picker-state-help">
155 {"If the prompts don't appear: System Settings → Privacy & Security → enable Screen under Camera, Microphone, and Screen Recording."}
156 </p>
157 </div>
158 }
159 .into_any(),
160 (CameraPermission::Granted, list) => view! {
161 <ul class="camera-picker-list" role="none">
162 {list
163 .into_iter()
164 .map(|cam| render_row(cam, selected_id.get(), on_select))
165 .collect_view()}
166 </ul>
167 }
168 .into_any(),
169 }
170 }
171}
172
173fn refresh_cameras(
174 cameras: RwSignal<Vec<CameraView>>,
175 selected_id: RwSignal<Option<String>>,
176 permission: RwSignal<CameraPermission>,
177 autostart_preview: bool,
178) {
179 spawn_local(async move {
180 let perm = camera_ipc::camera_permission_status().await;
181 permission.set(perm);
182 let list = camera_ipc::list_cameras().await;
183 let default_id = selected_id
184 .get_untracked()
185 .filter(|id| list.iter().any(|cam| cam.id == *id))
186 .or_else(|| resolve_default(&list));
187 if autostart_preview && let Some(id) = &default_id {
188 let id = id.clone();
189 spawn_local(async move {
190 camera_ipc::start_preview(id).await;
191 });
192 }
193 selected_id.set(default_id);
194 cameras.set(list);
195 });
196}
197
198fn render_row(
199 cam: CameraView,
200 selected: Option<String>,
201 on_select: Callback<String>,
202) -> impl IntoView {
203 let is_selected = selected.as_deref() == Some(cam.id.as_str());
204 let mut class = String::from("camera-picker-row");
205 if is_selected {
206 class.push_str(" camera-picker-row-selected");
207 }
208 let id_for_click = cam.id.clone();
209 let id_aria = cam.id.clone();
210 let label = cam.label.clone();
211 view! {
212 <li>
213 <button
214 class=class
215 role="option"
216 aria-selected=is_selected
217 data-camera-id=id_aria
218 on:click=move |_| on_select.run(id_for_click.clone())
219 >
220 <span class="camera-picker-row-label">{label}</span>
221 {is_selected.then(|| view! {
222 <span class="camera-picker-row-check" aria-hidden="true">"✓"</span>
223 })}
224 </button>
225 </li>
226 }
227}
228
229fn selected_label(cameras: &[CameraView], selected_id: Option<&String>) -> String {
230 let Some(id) = selected_id else {
231 return "Select camera".into();
232 };
233 cameras
234 .iter()
235 .find(|c| c.id == *id)
236 .map_or_else(|| "Select camera".to_string(), |c| c.label.clone())
237}
238
239fn resolve_default(cameras: &[CameraView]) -> Option<String> {
245 if cameras.is_empty() {
246 return None;
247 }
248 if let Some(last) = read_last_used()
249 && cameras.iter().any(|c| c.id == last)
250 {
251 return Some(last);
252 }
253 cameras
254 .iter()
255 .find(|c| c.is_default)
256 .map(|c| c.id.clone())
257 .or_else(|| cameras.first().map(|c| c.id.clone()))
258}
259
260#[cfg(target_arch = "wasm32")]
261fn read_last_used() -> Option<String> {
262 let storage = web_sys::window()?.local_storage().ok().flatten()?;
263 storage.get_item(LAST_USED_KEY).ok().flatten()
264}
265
266#[cfg(not(target_arch = "wasm32"))]
267fn read_last_used() -> Option<String> {
268 None
272}
273
274#[cfg(target_arch = "wasm32")]
275fn write_last_used(id: &str) {
276 let Some(window) = web_sys::window() else {
277 return;
278 };
279 let Ok(Some(storage)) = window.local_storage() else {
280 return;
281 };
282 let _ = storage.set_item(LAST_USED_KEY, id);
283}
284
285#[cfg(not(target_arch = "wasm32"))]
286fn write_last_used(_id: &str) {
287 }
290
291#[cfg(target_arch = "wasm32")]
294fn log_to_console(msg: &str) {
295 web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(msg));
296}
297
298#[cfg(not(target_arch = "wasm32"))]
299fn log_to_console(_msg: &str) {}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 fn cam(id: &str, label: &str, is_default: bool) -> CameraView {
306 CameraView {
307 id: id.into(),
308 label: label.into(),
309 is_default,
310 }
311 }
312
313 #[test]
314 fn resolve_default_returns_none_for_empty() {
315 assert_eq!(resolve_default(&[]), None);
316 }
317
318 #[test]
319 fn resolve_default_picks_is_default_when_no_persisted() {
320 let list = vec![
321 cam("a", "Cam A", false),
322 cam("b", "Cam B", true),
323 cam("c", "Cam C", false),
324 ];
325 assert_eq!(resolve_default(&list), Some("b".into()));
329 }
330
331 #[test]
332 fn resolve_default_falls_back_to_first_when_none_default() {
333 let list = vec![cam("a", "Cam A", false), cam("b", "Cam B", false)];
334 assert_eq!(resolve_default(&list), Some("a".into()));
335 }
336
337 #[test]
338 fn selected_label_returns_placeholder_when_unselected() {
339 assert_eq!(selected_label(&[], None), "Select camera");
340 }
341
342 #[test]
343 fn selected_label_returns_camera_label_when_selected() {
344 let list = vec![cam("a", "FaceTime HD", true)];
345 let id = "a".to_string();
346 assert_eq!(selected_label(&list, Some(&id)), "FaceTime HD");
347 }
348}