Skip to main content

app_ui/
app.rs

1//! Top-level `App` component for the recorder shell.
2
3use leptos::html;
4use leptos::prelude::*;
5use ui_storybook::components::{
6    DropZone, DropZoneState, PlayState, PlayerControls, RecordingState, RecordingToolbar,
7    StatusBar, StatusKind,
8};
9use wasm_bindgen::JsCast;
10use wasm_bindgen::prelude::*;
11use web_sys::{CustomEvent, HtmlVideoElement};
12
13use crate::player_ipc::{
14    self, PlayerStatus, SessionState, convert_file_src, install_player_status_listener,
15    screen_open, screen_pause, screen_play,
16};
17
18/// The recorder shell. Composes the toolbar, the main surface (drop-zone
19/// or player view), and the status bar.
20#[component]
21pub fn App() -> impl IntoView {
22    // Loaded-recording signal. `None` = drop-zone view; `Some(path)` =
23    // player view. Two paths to set it:
24    //   1. Tauri's `file-dropped` event → JS bridge dispatches a browser
25    //      `CustomEvent("file-dropped")` → the listener below.
26    //   2. The CSR demo-affordance click handler (still wired so the
27    //      browser-only `trunk serve` path stays exercisable).
28    let (loaded, set_loaded) = signal::<Option<String>>(None);
29
30    // Pushed `player-status` events flow into this signal — the player
31    // view re-renders whenever the Rust-side session state changes.
32    let (player_status, set_player_status) = signal::<PlayerStatus>(PlayerStatus::default());
33
34    // Drag-over visual feedback (M-POLISH.1). Flips on `file-drag-enter`,
35    // resets on `file-drag-leave` (which Tauri fires after a successful
36    // drop too — see main.rs's `on_window_event`).
37    let (is_dragging, set_dragging) = signal::<bool>(false);
38
39    install_file_drop_listener(set_loaded);
40    install_player_status_listener(set_player_status);
41    install_drag_state_listeners(set_dragging);
42
43    let on_demo_load = move |_| {
44        set_loaded.set(Some("Recording 01.mp4 (demo)".into()));
45    };
46
47    view! {
48        <div class="shell">
49            <RecordingToolbar
50                state=RecordingState::Idle
51                elapsed_seconds=0.0
52                source="Built-in Display"
53            />
54
55            <main class="shell-main">
56                <Show
57                    when=move || loaded.get().is_some()
58                    fallback=move || {
59                        let drop_state = move || if is_dragging.get() {
60                            DropZoneState::Active
61                        } else {
62                            DropZoneState::Idle
63                        };
64                        view! {
65                            <div class="shell-drop-wrap" on:click=on_demo_load>
66                                {move || view! {
67                                    <DropZone
68                                        state=drop_state()
69                                        hint="Drop an MP4 here, or click for a demo"
70                                    />
71                                }}
72                            </div>
73                        }
74                    }
75                >
76                    <PlayerView loaded=loaded player_status=player_status />
77                </Show>
78            </main>
79
80            <StatusBar
81                fps=60.0
82                encoder="H.264 · idle"
83                file_bytes=0
84                kind=StatusKind::Ready
85            />
86        </div>
87    }
88}
89
90#[component]
91fn PlayerView(
92    loaded: ReadSignal<Option<String>>,
93    player_status: ReadSignal<PlayerStatus>,
94) -> impl IntoView {
95    let video_ref: NodeRef<html::Video> = NodeRef::new();
96
97    // Resolve the dropped file path → asset:// URL for the <video> tag.
98    // `convert_file_src` returns `None` outside Tauri (browser-only
99    // `trunk serve` path) — render a plain message in that case.
100    let video_src = move || -> Option<String> {
101        let path = loaded.get()?;
102        convert_file_src(&path)
103    };
104    let path_label = move || loaded.get().unwrap_or_default();
105
106    // Catch-up effect: when Tauri's state changes for non-click reasons
107    // (Ended on EOF, future seek, ...), keep the <video> element in
108    // sync. Idempotent — won't fight the click-handler driven path.
109    Effect::new(move |_| {
110        let status = player_status.get();
111        let Some(video) = video_ref.get() else {
112            return;
113        };
114        let video: HtmlVideoElement = video;
115        let should_play = status.state == SessionState::Playing;
116        let is_paused = video.paused();
117        if should_play && is_paused {
118            let _ = video.play();
119        } else if !should_play && !is_paused {
120            let _ = video.pause();
121        }
122    });
123
124    let controls = move || {
125        let status = player_status.get();
126        let play_state = match status.state {
127            SessionState::Playing => PlayState::Playing,
128            _ => PlayState::Paused,
129        };
130        let position = player_ipc::position(&status);
131        let duration = player_ipc::duration_seconds(&status);
132        let toggle = Callback::new(move |()| {
133            let cur = player_status.get_untracked();
134            let want_playing = cur.state != SessionState::Playing;
135
136            // Drive the <video> element synchronously inside the click
137            // handler — WebKit blocks programmatic .play() outside a
138            // user gesture, so the catch-up Effect alone isn't enough.
139            if let Some(video) = video_ref.get_untracked() {
140                let video: HtmlVideoElement = video;
141                let _ = if want_playing {
142                    let _ = video.play();
143                    Ok::<(), JsValue>(())
144                } else {
145                    video.pause()
146                };
147            }
148
149            // Drive Tauri Player state in parallel — source of truth
150            // for elapsed/duration/EOF transitions.
151            let _ = if want_playing {
152                screen_play()
153            } else {
154                screen_pause()
155            };
156        });
157        view! {
158            <PlayerControls
159                state=play_state
160                position=position
161                duration_seconds=duration
162                on_toggle=toggle
163            />
164        }
165    };
166
167    view! {
168        <div class="player-view">
169            <div class="player-surface">
170                {move || video_src().map(|src| view! {
171                    <video
172                        node_ref=video_ref
173                        class="player-video"
174                        src=src
175                        preload="auto"
176                    />
177                })}
178                <div class="player-surface-label">
179                    "Preview surface · " {path_label}
180                </div>
181            </div>
182            {controls}
183        </div>
184    }
185}
186
187/// Install a `file-dropped` browser-event listener on the global `window`.
188///
189/// The Tauri shell's JS bridge in `index.html` re-emits Tauri's native
190/// drag-drop event as a `CustomEvent`. We listen here and forward the
191/// payload into the loaded signal *and* invoke the Rust-side
192/// `player_open` command. The Tauri command will then push a
193/// `player-status` event back with the freshly-decoded file's metadata.
194///
195/// The closure is leaked via `Closure::forget` because it has app
196/// lifetime — the listener should never be removed.
197fn install_file_drop_listener(set_loaded: WriteSignal<Option<String>>) {
198    let Some(window) = web_sys::window() else {
199        return;
200    };
201    let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
202        if let Ok(ce) = event.dyn_into::<CustomEvent>()
203            && let Some(path) = ce.detail().as_string()
204        {
205            // Best-effort IPC call. Errors (e.g. running outside Tauri
206            // via `trunk serve`) are intentionally ignored — the drop-
207            // zone-to-player transition still works for the demo path.
208            let _ = screen_open(&path);
209            set_loaded.set(Some(path));
210        }
211    }) as Box<dyn FnMut(_)>);
212    let _ =
213        window.add_event_listener_with_callback("file-dropped", closure.as_ref().unchecked_ref());
214    closure.forget();
215}
216
217/// Wire the `file-drag-enter` / `file-drag-leave` browser-event
218/// listeners that flip the `is_dragging` signal driving the
219/// `<DropZone>`'s active visual state. Both closures are leaked
220/// (app-lifetime listeners), matching the file-drop-listener pattern.
221fn install_drag_state_listeners(set_dragging: WriteSignal<bool>) {
222    let Some(window) = web_sys::window() else {
223        return;
224    };
225    let enter = Closure::wrap(Box::new(move |_: web_sys::Event| {
226        set_dragging.set(true);
227    }) as Box<dyn FnMut(_)>);
228    let _ =
229        window.add_event_listener_with_callback("file-drag-enter", enter.as_ref().unchecked_ref());
230    enter.forget();
231
232    let leave = Closure::wrap(Box::new(move |_: web_sys::Event| {
233        set_dragging.set(false);
234    }) as Box<dyn FnMut(_)>);
235    let _ =
236        window.add_event_listener_with_callback("file-drag-leave", leave.as_ref().unchecked_ref());
237    leave.forget();
238}