app_ui/player_ipc.rs
1//! Leptos-side bindings for the screen-app player IPC.
2//!
3//! Three responsibilities:
4//!
5//! 1. Mirror the Rust-side `screen_app::player_session::PlayerStatus`
6//! payload (kept in sync manually — they must match the
7//! `Serialize`/`Deserialize` shape on both ends). The link is
8//! deliberately plain text: `app-ui` is a WASM crate and can't depend
9//! on `screen-app` (Tauri-native), so the type can't be resolved by
10//! rustdoc.
11//! 2. Expose `extern "C"` thin Rust wrappers around the JS helpers
12//! `__screenOpen` / `__screenPlay` / `__screenPause` declared in
13//! `index.html`. Returns are `Result<JsValue, JsValue>` (`catch`-style)
14//! so the calls degrade to no-ops when running outside Tauri.
15//! 3. [`install_player_status_listener`] wires a `player-status`
16//! browser-`CustomEvent` listener and pushes the parsed payload
17//! into a Leptos `WriteSignal<PlayerStatus>`.
18
19use leptos::prelude::{Set, WriteSignal};
20use serde::Deserialize;
21use wasm_bindgen::JsCast;
22use wasm_bindgen::prelude::*;
23use web_sys::CustomEvent;
24
25/// IPC-stable mirror of `screen_app::player_session::SessionState`
26/// (plain-text reference — see the crate-level docs for why).
27/// `serde(rename_all = "lowercase")` matches the Rust-side serialization.
28#[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
29#[serde(rename_all = "lowercase")]
30pub enum SessionState {
31 /// No file is loaded.
32 #[default]
33 Empty,
34 /// A file is loaded; the player is not advancing.
35 Paused,
36 /// A file is loaded; the player is advancing in real time.
37 Playing,
38 /// The stream reached EOF.
39 Ended,
40}
41
42/// IPC-stable mirror of `screen_app::player_session::PlayerStatus`.
43#[derive(Deserialize, Clone, Debug, Default, PartialEq)]
44pub struct PlayerStatus {
45 /// Lifecycle state.
46 pub state: SessionState,
47 /// Wallclock-elapsed since `play` was first called, in milliseconds.
48 pub elapsed_ms: u64,
49 /// Total duration in milliseconds, when known.
50 pub duration_ms: Option<u64>,
51 /// Native frame width in pixels.
52 pub width: u32,
53 /// Native frame height in pixels.
54 pub height: u32,
55 /// Source frame rate (frames per second).
56 pub fps: f32,
57 /// Total frame count, when known.
58 pub frame_count: Option<u64>,
59}
60
61#[wasm_bindgen]
62extern "C" {
63 /// Open a file. Bridge throws if `__TAURI__` is unavailable
64 /// (browser-only `trunk serve` path) — we ignore the error.
65 #[wasm_bindgen(js_namespace = window, js_name = "__screenOpen", catch)]
66 pub fn screen_open(path: &str) -> Result<JsValue, JsValue>;
67
68 /// Resume playback.
69 #[wasm_bindgen(js_namespace = window, js_name = "__screenPlay", catch)]
70 pub fn screen_play() -> Result<JsValue, JsValue>;
71
72 /// Pause playback.
73 #[wasm_bindgen(js_namespace = window, js_name = "__screenPause", catch)]
74 pub fn screen_pause() -> Result<JsValue, JsValue>;
75
76 /// Synchronous Tauri helper: convert a local file path into the
77 /// asset-protocol URL the webview can load via `<video src>`.
78 /// Returns the converted string as a `JsValue`, or `JsValue::UNDEFINED`
79 /// when running outside Tauri.
80 #[wasm_bindgen(js_namespace = window, js_name = "__screenConvertFileSrc")]
81 pub fn screen_convert_file_src_js(path: &str) -> JsValue;
82}
83
84/// Convert a local file path to an asset-protocol URL the `<video>`
85/// element can load. Returns `None` when running outside Tauri (the
86/// standalone `trunk serve` browser-only path) — callers can render a
87/// placeholder in that case.
88#[must_use]
89pub fn convert_file_src(path: &str) -> Option<String> {
90 let value = screen_convert_file_src_js(path);
91 if value.is_undefined() || value.is_null() {
92 return None;
93 }
94 value.as_string()
95}
96
97/// Install a `player-status` browser-`CustomEvent` listener.
98///
99/// The Tauri shell's JS bridge re-emits the Tauri-side `player-status`
100/// events as browser `CustomEvent`s whose `.detail` is the [`PlayerStatus`]
101/// payload as a JS object. We deserialize via `serde-wasm-bindgen` and
102/// push into the supplied signal.
103///
104/// The closure is leaked via `Closure::forget` because the listener has
105/// app-lifetime — it should never be removed.
106pub fn install_player_status_listener(set_status: WriteSignal<PlayerStatus>) {
107 let Some(window) = web_sys::window() else {
108 return;
109 };
110 let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
111 if let Ok(ce) = event.dyn_into::<CustomEvent>()
112 && let Ok(status) = serde_wasm_bindgen::from_value::<PlayerStatus>(ce.detail())
113 {
114 set_status.set(status);
115 }
116 }) as Box<dyn FnMut(_)>);
117 let _ =
118 window.add_event_listener_with_callback("player-status", closure.as_ref().unchecked_ref());
119 closure.forget();
120}
121
122/// Position fraction `0.0..=1.0` from elapsed/duration. Returns `0.0`
123/// when duration is unknown or zero.
124#[must_use]
125pub fn position(status: &PlayerStatus) -> f32 {
126 let Some(d) = status.duration_ms else {
127 return 0.0;
128 };
129 if d == 0 {
130 return 0.0;
131 }
132 fraction_ms(status.elapsed_ms, d)
133}
134
135/// Total duration in seconds. Falls back to `60.0` (matches the
136/// `PlayerControls` component's own fallback) when not reported.
137#[must_use]
138pub fn duration_seconds(status: &PlayerStatus) -> f32 {
139 let Some(d) = status.duration_ms else {
140 return 60.0;
141 };
142 ms_to_seconds(d)
143}
144
145#[allow(
146 clippy::cast_precision_loss,
147 reason = "scrub-bar UI; ms values fit comfortably in f64 and the f32 result is for layout only"
148)]
149#[allow(
150 clippy::cast_possible_truncation,
151 reason = "f64 fraction is bounded by [0, ~1.0], well within f32 range"
152)]
153fn fraction_ms(num_ms: u64, denom_ms: u64) -> f32 {
154 let f = (num_ms as f64) / (denom_ms as f64);
155 f as f32
156}
157
158#[allow(
159 clippy::cast_precision_loss,
160 clippy::cast_possible_truncation,
161 reason = "duration display tolerates ~ms-rounded f32 (UI shows whole seconds anyway); ms / 1000 fits in f32 for any realistic video"
162)]
163fn ms_to_seconds(ms: u64) -> f32 {
164 (ms as f64 / 1000.0) as f32
165}