Skip to main content

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}