1use edit::EditProject;
14use leptos::prelude::{RwSignal, Set};
15use serde::{Deserialize, Serialize};
16use wasm_bindgen::JsCast;
17use wasm_bindgen::prelude::*;
18use web_sys::CustomEvent;
19
20#[wasm_bindgen]
21extern "C" {
22 #[wasm_bindgen(js_namespace = window, js_name = "__screenOpenInEditor", catch)]
27 pub fn screen_open_in_editor(path: &str) -> Result<JsValue, JsValue>;
28}
29
30pub fn install_editor_project_listener(project: RwSignal<Option<EditProject>>) {
35 let Some(window) = web_sys::window() else {
36 return;
37 };
38 let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
39 if let Ok(custom) = event.dyn_into::<CustomEvent>()
40 && let Ok(loaded) = serde_wasm_bindgen::from_value::<EditProject>(custom.detail())
41 {
42 project.set(Some(loaded));
43 }
44 }) as Box<dyn FnMut(_)>);
45 let _ =
46 window.add_event_listener_with_callback("editor-project", closure.as_ref().unchecked_ref());
47 closure.forget();
48}
49
50#[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq)]
54pub struct EditorStatus {
55 pub current_frame: u64,
57 pub duration_frames: u64,
59 pub playing: bool,
61 #[serde(default)]
63 pub fps: u32,
64 #[serde(default)]
66 pub rate: f32,
67 pub in_frame: u64,
69 pub out_frame: u64,
71 pub looping: bool,
73}
74
75#[derive(Serialize, Clone, Copy, Debug, PartialEq)]
79#[serde(tag = "kind", rename_all = "snake_case")]
80pub enum TransportAction {
81 Play,
83 Pause,
85 TogglePlay,
87 Tick {
89 dt_ms: u32,
91 },
92 Seek {
94 frame: u64,
96 },
97 Step {
99 delta: i64,
101 },
102 SetRate {
104 rate: f32,
106 },
107 SetInOut {
109 a: u64,
111 b: u64,
113 },
114 ClearInOut,
116 SetLooping {
118 looping: bool,
120 },
121 SetDuration {
123 frames: u64,
125 },
126 Status,
128}
129
130#[wasm_bindgen]
131extern "C" {
132 #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorTransport", catch)]
135 fn screen_editor_transport_js(action: JsValue) -> Result<JsValue, JsValue>;
136}
137
138pub fn editor_transport(action: &TransportAction) {
141 if let Ok(js) = serde_wasm_bindgen::to_value(action) {
142 let _ = screen_editor_transport_js(js);
143 }
144}
145
146pub fn install_editor_status_listener(status: RwSignal<EditorStatus>) {
149 let Some(window) = web_sys::window() else {
150 return;
151 };
152 let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
153 if let Ok(custom) = event.dyn_into::<CustomEvent>()
154 && let Ok(parsed) = serde_wasm_bindgen::from_value::<EditorStatus>(custom.detail())
155 {
156 status.set(parsed);
157 }
158 }) as Box<dyn FnMut(_)>);
159 let _ =
160 window.add_event_listener_with_callback("editor-status", closure.as_ref().unchecked_ref());
161 closure.forget();
162}
163
164#[derive(Clone, Copy, Debug, Default, Deserialize)]
168pub struct ExportProgress {
169 pub done: u64,
171 pub total: u64,
173}
174
175#[derive(Clone, Debug, PartialEq, Eq, Default)]
177pub enum ExportUiState {
178 #[default]
180 Idle,
181 Running {
183 done: u64,
185 total: u64,
187 },
188 Done {
190 path: String,
192 },
193 Error {
195 message: String,
197 },
198}
199
200#[must_use]
202pub fn export_percent(done: u64, total: u64) -> u32 {
203 if total == 0 {
204 return 0;
205 }
206 u32::try_from(done.saturating_mul(100) / total)
207 .unwrap_or(100)
208 .min(100)
209}
210
211#[wasm_bindgen]
212extern "C" {
213 #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorExport", catch)]
216 fn screen_editor_export_js(project: JsValue, format: JsValue) -> Result<JsValue, JsValue>;
217
218 #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorExportCancel", catch)]
220 fn screen_editor_export_cancel_js() -> Result<JsValue, JsValue>;
221
222 #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorPreviewOpen", catch)]
226 fn screen_editor_preview_open_js(project: JsValue) -> Result<JsValue, JsValue>;
227}
228
229pub fn editor_preview_open(project: &EditProject) {
234 if let Ok(js) = serde_wasm_bindgen::to_value(project) {
235 let _ = screen_editor_preview_open_js(js);
236 }
237}
238
239pub fn editor_export(project: &EditProject, format: &str) {
242 if let Ok(js) = serde_wasm_bindgen::to_value(project) {
243 let _ = screen_editor_export_js(js, JsValue::from_str(format));
244 }
245}
246
247pub fn editor_export_cancel() {
249 let _ = screen_editor_export_cancel_js();
250}
251
252fn on_custom_event(name: &str, mut handler: impl FnMut(CustomEvent) + 'static) {
253 let Some(window) = web_sys::window() else {
254 return;
255 };
256 let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
257 if let Ok(custom) = event.dyn_into::<CustomEvent>() {
258 handler(custom);
259 }
260 }) as Box<dyn FnMut(_)>);
261 let _ = window.add_event_listener_with_callback(name, closure.as_ref().unchecked_ref());
262 closure.forget();
263}
264
265pub fn install_editor_export_listeners(state: RwSignal<ExportUiState>) {
268 on_custom_event("editor-export-progress", move |custom| {
269 if let Ok(p) = serde_wasm_bindgen::from_value::<ExportProgress>(custom.detail()) {
270 state.set(ExportUiState::Running {
271 done: p.done,
272 total: p.total,
273 });
274 }
275 });
276 on_custom_event("editor-export-done", move |custom| {
277 let path = custom.detail().as_string().unwrap_or_default();
278 state.set(ExportUiState::Done { path });
279 });
280 on_custom_event("editor-export-error", move |custom| {
281 let message = custom
282 .detail()
283 .as_string()
284 .unwrap_or_else(|| "export failed".to_owned());
285 state.set(ExportUiState::Error { message });
286 });
287}
288
289#[wasm_bindgen]
290extern "C" {
291 #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorSaveProject", catch)]
294 fn screen_editor_save_project_js(project: JsValue) -> Result<JsValue, JsValue>;
295}
296
297pub fn editor_save_project(project: &EditProject) {
300 if let Ok(js) = serde_wasm_bindgen::to_value(project) {
301 let _ = screen_editor_save_project_js(js);
302 }
303}
304
305pub fn install_editor_saved_listener(saved: RwSignal<Option<String>>) {
307 on_custom_event("editor-saved", move |custom| {
308 saved.set(custom.detail().as_string());
309 });
310}
311
312#[derive(Clone, Debug, PartialEq, Eq, Default, Deserialize)]
316pub struct RecordingEntry {
317 pub path: String,
319 pub name: String,
321 pub has_project: bool,
323}
324
325#[wasm_bindgen]
326extern "C" {
327 #[wasm_bindgen(js_namespace = window, js_name = "__screenListRecordings", catch)]
329 fn screen_list_recordings_js() -> Result<JsValue, JsValue>;
330}
331
332pub fn list_recordings() {
334 let _ = screen_list_recordings_js();
335}
336
337pub fn install_recordings_listener(entries: RwSignal<Vec<RecordingEntry>>) {
339 on_custom_event("recordings-listed", move |custom| {
340 if let Ok(list) = serde_wasm_bindgen::from_value::<Vec<RecordingEntry>>(custom.detail()) {
341 entries.set(list);
342 }
343 });
344}
345
346#[must_use]
352pub(crate) fn looks_like_video(path: &str) -> bool {
353 let lower = path.to_ascii_lowercase();
354 [".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi"]
355 .iter()
356 .any(|ext| lower.ends_with(ext))
357}
358
359pub fn install_file_drop_to_editor_listener() {
365 on_custom_event("file-dropped", move |custom| {
366 if let Some(path) = custom.detail().as_string()
367 && looks_like_video(&path)
368 {
369 let _ = screen_open_in_editor(&path);
370 }
371 });
372}
373
374pub fn install_drag_active_listeners(active: RwSignal<bool>) {
378 on_custom_event("file-drag-enter", move |_| active.set(true));
379 on_custom_event("file-drag-leave", move |_| active.set(false));
380}
381
382#[cfg(test)]
383mod export_tests {
384 use super::export_percent;
385
386 #[test]
387 fn percent_is_clamped_and_zero_safe() {
388 assert_eq!(export_percent(0, 0), 0);
389 assert_eq!(export_percent(0, 200), 0);
390 assert_eq!(export_percent(100, 200), 50);
391 assert_eq!(export_percent(200, 200), 100);
392 assert_eq!(export_percent(999, 200), 100); }
394}
395
396#[cfg(test)]
397mod drop_tests {
398 use super::looks_like_video;
399
400 #[test]
401 fn recognizes_video_extensions_case_insensitively() {
402 assert!(looks_like_video("/recordings/Screen-2026-05-31.mp4"));
403 assert!(looks_like_video("/x/clip.MOV"));
404 assert!(looks_like_video("/x/a.WebM"));
405 assert!(looks_like_video("/x/b.mkv"));
406 assert!(!looks_like_video("/x/notes.txt"));
408 assert!(!looks_like_video("/x/image.png"));
409 assert!(!looks_like_video("/x/screenshot.mp4.txt"));
410 assert!(!looks_like_video("/x/noext"));
411 }
412}