Skip to main content

app_ui/
screen_preview_canvas.rs

1//! Live screen-capture preview canvas (AUT-269).
2//!
3//! Replaces the recorder's mock display-preview with the **real** screen being
4//! captured. Mirrors [`crate::camera_preview`] / [`crate::editor_preview_canvas`]:
5//! an [`Effect`] starts/stops the downscaled preview capture as the display
6//! section opens/closes (or the selected source changes), and a `setInterval`
7//! poll paints the latest frame (`latest_screen_frame_bgra`) into the canvas.
8//!
9//! The backend captures downscaled to 1280×720 (`screen_capture::PREVIEW_WIDTH`
10//! / `PREVIEW_HEIGHT`) and excludes the recorder's own windows, so the poll is
11//! cheap and the preview doesn't capture itself (the screen-of-its-own-screen
12//! feedback loop).
13
14use leptos::prelude::*;
15
16#[cfg(target_arch = "wasm32")]
17use leptos::task::spawn_local;
18#[cfg(target_arch = "wasm32")]
19use wasm_bindgen::JsCast;
20#[cfg(target_arch = "wasm32")]
21use wasm_bindgen::prelude::*;
22
23/// Stable DOM id for the screen-preview `<canvas>` (found by the poll).
24pub const SCREEN_PREVIEW_CANVAS_ID: &str = "screen-preview-canvas";
25
26/// Preview frame width — must match the backend `screen_capture::PREVIEW_WIDTH`
27/// so the polled BGRA bytes fit the canvas pixel-for-pixel (`putImageData`
28/// does not scale). The two crates can't share a const (native vs wasm).
29pub const SCREEN_PREVIEW_WIDTH: u32 = 1280;
30/// Preview frame height — matches the backend `screen_capture::PREVIEW_HEIGHT`.
31pub const SCREEN_PREVIEW_HEIGHT: u32 = 720;
32/// ~15 fps poll interval (ms) — matches the camera/editor previews.
33pub const SCREEN_PREVIEW_POLL_MS: i32 = 66;
34
35/// Install the live screen-preview hooks (AUT-269): start/stop the downscaled
36/// preview capture as `active` + `source` change, plus the repaint poll. Call
37/// **once** from the recorder body (not a reactive block, or each rebuild
38/// leaks another `setInterval`). `active` = "the display section is showing";
39/// `source` = the selected display/window id (`None` = primary display).
40#[cfg(target_arch = "wasm32")]
41pub fn install_screen_preview(active: Memo<bool>, source: RwSignal<Option<String>>) {
42    // Start / stop the downscaled preview capture as visibility + source
43    // change. `start_screen_capture` drops any in-flight stream first, so a
44    // source change while active cleanly re-targets; a permission denial just
45    // leaves the canvas blank (best-effort).
46    Effect::new(move |_| {
47        let on = active.get();
48        let src = source.get();
49        spawn_local(async move {
50            if on {
51                let _ = crate::screen_ipc::start_screen_capture(src).await;
52            } else {
53                crate::screen_ipc::stop_screen_capture().await;
54            }
55        });
56    });
57    on_cleanup(|| {
58        spawn_local(async {
59            crate::screen_ipc::stop_screen_capture().await;
60        });
61    });
62
63    let closure = Closure::wrap(Box::new(move || {
64        spawn_local(async move {
65            paint_one_screen_frame().await;
66        });
67    }) as Box<dyn FnMut()>);
68    if let Some(window) = web_sys::window() {
69        let _ = window.set_interval_with_callback_and_timeout_and_arguments_0(
70            closure.as_ref().unchecked_ref(),
71            SCREEN_PREVIEW_POLL_MS,
72        );
73    }
74    closure.forget();
75}
76
77/// Native stub — the screen preview runs only in the wasm webview.
78#[cfg(not(target_arch = "wasm32"))]
79pub fn install_screen_preview(_active: Memo<bool>, _source: RwSignal<Option<String>>) {}
80
81/// One paint tick: find the canvas (skip the IPC entirely when the preview
82/// isn't shown — a cheap idle poll), request the latest downscaled frame, swap
83/// BGRA→RGBA, and `putImageData` it. Best-effort — every failure path no-ops.
84#[cfg(target_arch = "wasm32")]
85async fn paint_one_screen_frame() {
86    use js_sys::{Reflect, Uint8ClampedArray};
87    use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
88
89    let bytes_len = (SCREEN_PREVIEW_WIDTH as usize) * (SCREEN_PREVIEW_HEIGHT as usize) * 4;
90
91    let Some(window) = web_sys::window() else {
92        return;
93    };
94    let Some(document) = window.document() else {
95        return;
96    };
97    // Canvas-first: when the display section is closed the canvas is absent, so
98    // we skip the IPC call entirely and keep the idle poll cheap.
99    let Some(canvas_el) = document.get_element_by_id(SCREEN_PREVIEW_CANVAS_ID) else {
100        return;
101    };
102    let Ok(canvas) = canvas_el.dyn_into::<HtmlCanvasElement>() else {
103        return;
104    };
105
106    let Ok(invoke_fn) = Reflect::get(&window, &JsValue::from_str("__screenLatestScreenFrameBgra"))
107    else {
108        return;
109    };
110    if !invoke_fn.is_function() {
111        return;
112    }
113    let invoke: js_sys::Function = invoke_fn.unchecked_into();
114    let Ok(promise) = invoke.call0(&JsValue::NULL) else {
115        return;
116    };
117    let promise: js_sys::Promise = match promise.dyn_into() {
118        Ok(p) => p,
119        Err(_) => return,
120    };
121    let Ok(buf) = wasm_bindgen_futures::JsFuture::from(promise).await else {
122        return;
123    };
124
125    let bytes = if let Ok(array_buffer) = buf.clone().dyn_into::<js_sys::ArrayBuffer>() {
126        Uint8ClampedArray::new(&array_buffer)
127    } else if let Ok(typed_array) = buf.dyn_into::<Uint8ClampedArray>() {
128        typed_array
129    } else {
130        return;
131    };
132    // Empty / no-frame (no open session, or before the first frame): skip.
133    let len = bytes.length() as usize;
134    if len < bytes_len {
135        return;
136    }
137
138    let mut rgba = vec![0u8; bytes_len];
139    bytes.copy_to(&mut rgba[..bytes_len]);
140    for px in rgba.chunks_exact_mut(4) {
141        // BGRA → RGBA (the captured frame is BGRA, like the camera frame).
142        px.swap(0, 2);
143    }
144
145    let Ok(Some(ctx)) = canvas.get_context("2d") else {
146        return;
147    };
148    let Ok(ctx) = ctx.dyn_into::<CanvasRenderingContext2d>() else {
149        return;
150    };
151    let Ok(image_data) = ImageData::new_with_u8_clamped_array_and_sh(
152        wasm_bindgen::Clamped(&rgba[..]),
153        SCREEN_PREVIEW_WIDTH,
154        SCREEN_PREVIEW_HEIGHT,
155    ) else {
156        return;
157    };
158    let _ = ctx.put_image_data(&image_data, 0.0, 0.0);
159}