1use leptos::prelude::*;
29#[cfg(target_arch = "wasm32")]
30use leptos::task::spawn_local;
31#[cfg(target_arch = "wasm32")]
32use wasm_bindgen::JsCast;
33#[cfg(target_arch = "wasm32")]
34use wasm_bindgen::prelude::*;
35
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum RecorderPreviewState {
45 #[default]
47 Initialising,
48 AwaitingPermission,
50 PermissionDenied,
52 Live,
54}
55
56impl RecorderPreviewState {
57 #[must_use]
60 pub fn copy(self) -> &'static str {
61 match self {
62 Self::Initialising => "Starting camera…",
63 Self::AwaitingPermission => "Waiting for camera permission…",
64 Self::PermissionDenied => {
65 "Camera access denied. Grant access in System Settings → Privacy & Security, then re-open this surface."
66 }
67 Self::Live => "",
68 }
69 }
70
71 #[must_use]
73 pub fn slug(self) -> &'static str {
74 match self {
75 Self::Initialising => "initialising",
76 Self::AwaitingPermission => "awaiting-permission",
77 Self::PermissionDenied => "permission-denied",
78 Self::Live => "live",
79 }
80 }
81}
82
83pub const CANVAS_DOM_ID: &str = "camera-preview-canvas";
87
88pub const PREVIEW_CANVAS_WIDTH: u32 = 720;
94pub const PREVIEW_CANVAS_HEIGHT: u32 = 720;
96pub const PREVIEW_CANVAS_BYTES: usize =
98 (PREVIEW_CANVAS_WIDTH as usize) * (PREVIEW_CANVAS_HEIGHT as usize) * 4;
99pub const PREVIEW_POLL_MS: i32 = 66;
102
103#[component]
111pub fn CameraPreview() -> impl IntoView {
112 let state = RwSignal::new(RecorderPreviewState::default());
113 install_camera_frame_poll(state);
114 view! {
115 <section
116 class="camera-preview-surface"
117 data-state=move || state.get().slug()
118 >
119 <canvas
120 id=CANVAS_DOM_ID
121 class="camera-preview"
122 width=PREVIEW_CANVAS_WIDTH
123 height=PREVIEW_CANVAS_HEIGHT
124 aria-label="Live webcam preview (circular)"
125 />
126 <Show
127 when=move || !matches!(state.get(), RecorderPreviewState::Live)
128 fallback=|| view! { <></> }
129 >
130 <div class="camera-preview-overlay">
131 {move || state.get().copy()}
132 </div>
133 </Show>
134 </section>
135 }
136}
137
138#[cfg(target_arch = "wasm32")]
146fn install_camera_frame_poll(state: RwSignal<RecorderPreviewState>) {
147 let closure = Closure::wrap(Box::new(move || {
148 spawn_local(async move {
149 paint_one_frame(state).await;
150 });
151 }) as Box<dyn FnMut()>);
152 if let Some(window) = web_sys::window() {
153 let _ = window.set_interval_with_callback_and_timeout_and_arguments_0(
154 closure.as_ref().unchecked_ref(),
155 PREVIEW_POLL_MS,
156 );
157 }
158 closure.forget();
159}
160
161#[cfg(not(target_arch = "wasm32"))]
162fn install_camera_frame_poll(_state: RwSignal<RecorderPreviewState>) {}
163
164#[cfg(target_arch = "wasm32")]
170async fn paint_one_frame(state: RwSignal<RecorderPreviewState>) {
171 use js_sys::{Reflect, Uint8ClampedArray};
172 use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
173
174 let Some(window) = web_sys::window() else {
175 return;
176 };
177 let Ok(invoke_fn) = Reflect::get(&window, &JsValue::from_str("__screenLatestCameraFrameBgra"))
178 else {
179 return;
180 };
181 if !invoke_fn.is_function() {
182 return;
183 }
184 let invoke: js_sys::Function = invoke_fn.unchecked_into();
185 let Ok(promise) = invoke.call0(&JsValue::NULL) else {
186 return;
187 };
188 let promise: js_sys::Promise = match promise.dyn_into() {
189 Ok(p) => p,
190 Err(_) => return,
191 };
192 let result = wasm_bindgen_futures::JsFuture::from(promise).await;
193 let Ok(buf) = result else {
194 return;
195 };
196
197 let bytes = if let Ok(array_buffer) = buf.clone().dyn_into::<js_sys::ArrayBuffer>() {
199 Uint8ClampedArray::new(&array_buffer)
200 } else if let Ok(typed_array) = buf.dyn_into::<Uint8ClampedArray>() {
201 typed_array
202 } else {
203 return;
204 };
205 let len = bytes.length() as usize;
206 if len < PREVIEW_CANVAS_BYTES {
207 return;
208 }
209
210 let mut rgba = vec![0u8; PREVIEW_CANVAS_BYTES];
213 bytes.copy_to(&mut rgba[..PREVIEW_CANVAS_BYTES]);
214 for px in rgba.chunks_exact_mut(4) {
215 px.swap(0, 2);
217 }
218
219 let document = window.document();
223 let Some(document) = document else {
224 return;
225 };
226 let Some(canvas_el) = document.get_element_by_id(CANVAS_DOM_ID) else {
227 return;
228 };
229 let Ok(canvas) = canvas_el.dyn_into::<HtmlCanvasElement>() else {
230 return;
231 };
232 let Ok(Some(ctx)) = canvas.get_context("2d") else {
233 return;
234 };
235 let Ok(ctx) = ctx.dyn_into::<CanvasRenderingContext2d>() else {
236 return;
237 };
238
239 let Ok(image_data) = ImageData::new_with_u8_clamped_array_and_sh(
240 wasm_bindgen::Clamped(&rgba[..]),
241 PREVIEW_CANVAS_WIDTH,
242 PREVIEW_CANVAS_HEIGHT,
243 ) else {
244 return;
245 };
246 let _ = ctx.put_image_data(&image_data, 0.0, 0.0);
247
248 if !matches!(state.get_untracked(), RecorderPreviewState::Live) {
251 state.set(RecorderPreviewState::Live);
252 }
253}
254
255#[cfg(not(target_arch = "wasm32"))]
256#[allow(
257 dead_code,
258 clippy::unused_async,
259 reason = "native stub for symmetry with the wasm32 async impl; cargo check on native target sees no caller and no .await."
260)]
261async fn paint_one_frame(_state: RwSignal<RecorderPreviewState>) {}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn default_is_initialising() {
269 assert_eq!(
270 RecorderPreviewState::default(),
271 RecorderPreviewState::Initialising
272 );
273 }
274
275 #[test]
276 fn each_state_has_unique_slug() {
277 let states = [
278 RecorderPreviewState::Initialising,
279 RecorderPreviewState::AwaitingPermission,
280 RecorderPreviewState::PermissionDenied,
281 RecorderPreviewState::Live,
282 ];
283 let mut slugs: Vec<_> = states.iter().map(|s| s.slug()).collect();
284 slugs.sort_unstable();
285 slugs.dedup();
286 assert_eq!(slugs.len(), states.len());
287 }
288
289 #[test]
290 fn live_has_empty_copy() {
291 assert!(RecorderPreviewState::Live.copy().is_empty());
292 }
293
294 #[test]
295 fn non_live_states_have_non_empty_copy() {
296 for s in [
297 RecorderPreviewState::Initialising,
298 RecorderPreviewState::AwaitingPermission,
299 RecorderPreviewState::PermissionDenied,
300 ] {
301 assert!(!s.copy().is_empty(), "state {s:?} had empty copy");
302 }
303 }
304}