1use leptos::prelude::*;
22use leptos::task::spawn_local;
23use ui_storybook::components::primitives::ChevronDown;
24
25use crate::screen_ipc::{self, DisplaySourceView, ScreenSourcesResult, WindowSourceView};
26
27#[cfg(target_arch = "wasm32")]
36const LAST_USED_KEY: &str = "screen.screen_capture.last_source_id";
37
38#[component]
41pub fn ScreenPicker() -> impl IntoView {
42 let enabled = RwSignal::new(false);
43 let expanded = RwSignal::new(false);
44 let displays = RwSignal::new(Vec::<DisplaySourceView>::new());
45 let windows = RwSignal::new(Vec::<WindowSourceView>::new());
46 let error_message = RwSignal::new(Option::<String>::None);
47 let active_source_id = RwSignal::new(read_last_used());
51 let recording_lock = RwSignal::new(false);
54 crate::recording_ipc::install_recording_lock_listener(recording_lock);
55
56 spawn_local(async move {
59 let active = screen_ipc::screen_capture_status().await;
60 enabled.set(active);
61 });
62
63 let on_toggle_enabled = make_on_toggle_enabled(enabled, active_source_id, error_message);
64 let on_toggle_expand =
65 make_on_toggle_expand(expanded, displays, windows, error_message, active_source_id);
66 let on_pick_source = make_on_pick(enabled, active_source_id, error_message);
67
68 view! {
69 <div class="screen-picker">
70 <div class="screen-picker-header">
71 <button
72 type="button"
73 class="screen-picker-toggle"
74 role="switch"
75 aria-checked=move || enabled.get()
76 data-enabled=move || if enabled.get() { "true" } else { "false" }
77 prop:disabled=move || recording_lock.get()
78 title=move || if recording_lock.get() { "Recording in progress — stop the recording to toggle screen capture" } else { "" }
79 on:click=move |evt| {
80 if recording_lock.get() { return; }
81 on_toggle_enabled(evt);
82 }
83 >
84 <span class="screen-picker-icon" aria-hidden="true">"🖥"</span>
85 <span class="screen-picker-label">"Screen"</span>
86 <span class="screen-picker-state">
87 {move || if enabled.get() { "On" } else { "Off" }}
88 </span>
89 </button>
90 <button
91 type="button"
92 class="screen-picker-expand"
93 aria-haspopup="listbox"
94 aria-expanded=move || expanded.get()
95 on:click=on_toggle_expand
96 >
97 <span class="screen-picker-summary">"Sources"</span>
98 <span class="screen-picker-chevron" aria-hidden="true">
99 <ChevronDown />
100 </span>
101 </button>
102 </div>
103 <Show when=move || expanded.get() fallback=|| view! { <></> }>
104 <div class="screen-picker-menu" role="listbox">
105 <ScreenPickerBody
106 displays=displays
107 windows=windows
108 error_message=error_message
109 active_source_id=active_source_id
110 on_pick=on_pick_source
111 />
112 </div>
113 </Show>
114 </div>
115 }
116}
117
118fn make_on_toggle_enabled(
121 enabled: RwSignal<bool>,
122 active_source_id: RwSignal<Option<String>>,
123 error_message: RwSignal<Option<String>>,
124) -> impl Fn(leptos::ev::MouseEvent) + Copy + Send + Sync + 'static {
125 move |_| {
126 let next = !enabled.get();
127 enabled.set(next);
128 let source_for_start = active_source_id.get();
129 spawn_local(async move {
130 if next {
131 match screen_ipc::start_screen_capture(source_for_start).await {
132 Ok(()) => error_message.set(None),
133 Err(err) => {
134 enabled.set(false);
135 error_message.set(Some(err));
136 }
137 }
138 } else {
139 screen_ipc::stop_screen_capture().await;
140 }
141 });
142 }
143}
144
145fn make_on_toggle_expand(
147 expanded: RwSignal<bool>,
148 displays: RwSignal<Vec<DisplaySourceView>>,
149 windows: RwSignal<Vec<WindowSourceView>>,
150 error_message: RwSignal<Option<String>>,
151 active_source_id: RwSignal<Option<String>>,
152) -> impl Fn(leptos::ev::MouseEvent) + Copy + Send + Sync + 'static {
153 move |_| {
154 let next = !expanded.get();
155 expanded.set(next);
156 if !next {
157 return;
158 }
159 spawn_local(async move {
160 match screen_ipc::list_screen_displays().await {
161 ScreenSourcesResult::Ok(list) => {
162 error_message.set(None);
163 displays.set(list);
164 }
165 ScreenSourcesResult::Err(msg) => {
166 error_message.set(Some(msg));
167 displays.set(Vec::new());
168 }
169 }
170 match screen_ipc::list_screen_windows().await {
171 ScreenSourcesResult::Ok(list) => windows.set(list),
172 ScreenSourcesResult::Err(_) => windows.set(Vec::new()),
173 }
174 let displays_now = displays.get();
177 let windows_now = windows.get();
178 if let Some(id) = active_source_id.get()
179 && !displays_now.iter().any(|d| d.id == id)
180 && !windows_now.iter().any(|w| w.id == id)
181 {
182 active_source_id.set(None);
183 write_last_used(None);
184 }
185 });
186 }
187}
188
189fn make_on_pick(
192 enabled: RwSignal<bool>,
193 active_source_id: RwSignal<Option<String>>,
194 error_message: RwSignal<Option<String>>,
195) -> impl Fn(Option<String>) + Copy + Send + Sync + 'static {
196 move |source_id: Option<String>| {
197 active_source_id.set(source_id.clone());
198 write_last_used(source_id.as_deref());
199 if enabled.get() {
200 let source_clone = source_id.clone();
201 spawn_local(async move {
202 screen_ipc::stop_screen_capture().await;
203 match screen_ipc::start_screen_capture(source_clone).await {
204 Ok(()) => error_message.set(None),
205 Err(err) => {
206 enabled.set(false);
207 error_message.set(Some(err));
208 }
209 }
210 });
211 }
212 }
213}
214
215#[component]
216fn ScreenPickerBody(
217 displays: RwSignal<Vec<DisplaySourceView>>,
218 windows: RwSignal<Vec<WindowSourceView>>,
219 error_message: RwSignal<Option<String>>,
220 active_source_id: RwSignal<Option<String>>,
221 on_pick: impl Fn(Option<String>) + Copy + Send + Sync + 'static,
222) -> impl IntoView {
223 move || {
224 match (error_message.get(), displays.get(), windows.get()) {
225 (Some(msg), _, _) => view! {
226 <div class="screen-picker-state-msg screen-picker-state-msg--error">
227 <p>{"Couldn't list screen sources."}</p>
228 <p class="screen-picker-state-help">{msg}</p>
229 <p class="screen-picker-state-help">
230 {"Request access first; macOS will then add this app to System Settings. After enabling it, quit and reopen the app."}
231 </p>
232 <button
233 type="button"
234 class="screen-picker-state-button"
235 on:click=move |_| {
236 spawn_local(async move {
237 screen_ipc::request_screen_recording_permission().await;
238 screen_ipc::open_settings_screen_recording().await;
239 });
240 }
241 >
242 {"Request Screen Recording Access"}
243 </button>
244 </div>
245 }
246 .into_any(),
247 (None, d_list, w_list) if d_list.is_empty() && w_list.is_empty() => view! {
248 <div class="screen-picker-state-msg">
249 <p>{"No screen sources detected."}</p>
250 </div>
251 }
252 .into_any(),
253 (None, d_list, w_list) => {
254 let active = active_source_id.get();
255 let primary_active = active.is_none();
256 view! {
257 <div class="screen-picker-section-label">{"Displays"}</div>
258 <ul class="screen-picker-list" role="none">
259 <li
262 class="screen-picker-row"
263 role="option"
264 aria-selected=move || primary_active
265 data-active=move || if primary_active { "true" } else { "false" }
266 on:click=move |_| on_pick(None)
267 >
268 <span class="screen-picker-row-check" aria-hidden="true">
269 {if primary_active { "✓" } else { " " }}
270 </span>
271 <span class="screen-picker-row-label">{"Primary display (auto)"}</span>
272 <span class="screen-picker-row-sub">{"First connected display"}</span>
273 </li>
274 {d_list
275 .into_iter()
276 .map(|d| {
277 let id = d.id.clone();
278 let is_active = active.as_deref() == Some(id.as_str());
279 view! {
280 <li
281 class="screen-picker-row"
282 role="option"
283 aria-selected=is_active
284 data-active=if is_active { "true" } else { "false" }
285 on:click=move |_| on_pick(Some(id.clone()))
286 >
287 <span class="screen-picker-row-check" aria-hidden="true">
288 {if is_active { "✓" } else { " " }}
289 </span>
290 <span class="screen-picker-row-label">{d.label.clone()}</span>
291 <span class="screen-picker-row-sub">
292 {format!("{}×{}{}", d.width, d.height, if d.is_primary { " · primary" } else { "" })}
293 </span>
294 </li>
295 }
296 })
297 .collect_view()}
298 </ul>
299 <div class="screen-picker-section-label">{"Windows"}</div>
300 <ul class="screen-picker-list" role="none">
301 {w_list
302 .into_iter()
303 .map(|w| {
304 let id = w.id.clone();
305 let is_active = active.as_deref() == Some(id.as_str());
306 let title = if w.label.is_empty() { "(untitled)".to_string() } else { w.label.clone() };
307 view! {
308 <li
309 class="screen-picker-row"
310 role="option"
311 aria-selected=is_active
312 data-active=if is_active { "true" } else { "false" }
313 on:click=move |_| on_pick(Some(id.clone()))
314 >
315 <span class="screen-picker-row-check" aria-hidden="true">
316 {if is_active { "✓" } else { " " }}
317 </span>
318 <span class="screen-picker-row-label">{title}</span>
319 <span class="screen-picker-row-sub">
320 {format!("{} · {}×{}", w.display_name, w.width, w.height)}
321 </span>
322 </li>
323 }
324 })
325 .collect_view()}
326 </ul>
327 }
328 .into_any()
329 }
330 }
331 }
332}
333
334#[cfg(target_arch = "wasm32")]
335fn read_last_used() -> Option<String> {
336 let storage = web_sys::window()?.local_storage().ok().flatten()?;
337 let raw = storage.get_item(LAST_USED_KEY).ok().flatten()?;
338 if raw.is_empty() { None } else { Some(raw) }
339}
340
341#[cfg(not(target_arch = "wasm32"))]
342fn read_last_used() -> Option<String> {
343 None
344}
345
346#[cfg(target_arch = "wasm32")]
347fn write_last_used(id: Option<&str>) {
348 let Some(window) = web_sys::window() else {
349 return;
350 };
351 let Ok(Some(storage)) = window.local_storage() else {
352 return;
353 };
354 match id {
355 Some(value) => {
356 let _ = storage.set_item(LAST_USED_KEY, value);
357 }
358 None => {
359 let _ = storage.remove_item(LAST_USED_KEY);
360 }
361 }
362}
363
364#[cfg(not(target_arch = "wasm32"))]
365fn write_last_used(_id: Option<&str>) {}