Skip to main content

screen_app/
player_session.rs

1//! Tauri-side player session — the shared state behind the IPC commands.
2//!
3//! Holds a long-lived [`Application`] (built once at app boot) and an
4//! `Option<Inner>` carrying the live [`Player`] for the currently-open
5//! video. `open` builds a fresh `Inner`; `play`/`pause`/`tick` mutate it.
6//!
7//! All public methods take `&self` and lock internally. The Tauri tick
8//! thread and the IPC command handlers all share a single `PlayerSession`
9//! through `tauri::State<PlayerSession>`.
10
11use std::path::Path;
12use std::sync::Mutex;
13use std::time::{Duration, Instant};
14
15use decode::VideoStream;
16use decode::gstreamer_pipe::GstreamerPipeStream;
17use playback::{PlayState, Player};
18use serde::{Deserialize, Serialize};
19use wisp::application::{AppConfig, Application};
20
21/// IPC-stable enum for the player's lifecycle. Mirrored on the frontend.
22///
23/// Distinct from [`playback::PlayState`] in that it carries an explicit
24/// `Empty` variant for "no file loaded" — that's a Tauri-shell concern,
25/// not a player-state-machine concern.
26#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
27#[serde(rename_all = "lowercase")]
28pub enum SessionState {
29    /// No file is loaded. `open` transitions to `Paused`.
30    Empty,
31    /// A file is loaded; the player is not advancing.
32    Paused,
33    /// A file is loaded; `tick` advances `elapsed` and uploads frames.
34    Playing,
35    /// The stream reached EOF. `tick` is a no-op.
36    Ended,
37}
38
39/// IPC payload emitted on the `player-status` event and returned from the
40/// `player_open` / `player_status` commands.
41#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
42pub struct PlayerStatus {
43    /// Lifecycle state — see [`SessionState`].
44    pub state: SessionState,
45    /// Wallclock elapsed since `play` was first called for this file, in
46    /// milliseconds. Zero when paused at start; frozen across pause.
47    pub elapsed_ms: u64,
48    /// Total duration in milliseconds, when the decoder reports it.
49    /// `None` for live streams or formats without a duration header.
50    pub duration_ms: Option<u64>,
51    /// Native frame width (pixels). `0` when no file is open.
52    pub width: u32,
53    /// Native frame height (pixels). `0` when no file is open.
54    pub height: u32,
55    /// Source frame rate (frames per second). `0.0` when no file is open.
56    pub fps: f32,
57    /// Total frame count when the decoder reports it. `None` for live
58    /// streams or formats without a frame-count header.
59    pub frame_count: Option<u64>,
60}
61
62impl PlayerStatus {
63    fn empty() -> Self {
64        Self {
65            state: SessionState::Empty,
66            elapsed_ms: 0,
67            duration_ms: None,
68            width: 0,
69            height: 0,
70            fps: 0.0,
71            frame_count: None,
72        }
73    }
74}
75
76struct Inner {
77    player: Player,
78    last_tick: Instant,
79    width: u32,
80    height: u32,
81    fps: f32,
82    frame_count_hint: Option<u64>,
83}
84
85/// Tauri shell's player state. Shared via `tauri::State<PlayerSession>`.
86pub struct PlayerSession {
87    app: Application,
88    inner: Mutex<Option<Inner>>,
89}
90
91impl PlayerSession {
92    /// Boot the session — builds a [`wisp::application::Application`]
93    /// up-front so subsequent `open` calls don't pay device-init latency.
94    ///
95    /// # Panics
96    ///
97    /// Panics if no compatible GPU adapter is available. The recorder
98    /// can't function without one, so failing fast is preferable to
99    /// surfacing a deferred error through the IPC layer.
100    #[must_use]
101    pub fn new() -> Self {
102        let app = pollster::block_on(Application::new(AppConfig::default()))
103            .expect("wisp::Application::new — no compatible adapter");
104        Self {
105            app,
106            inner: Mutex::new(None),
107        }
108    }
109
110    /// Open a video file. Replaces any currently-loaded session.
111    ///
112    /// Returns the fresh [`PlayerStatus`] (the new session begins in
113    /// [`SessionState::Paused`]).
114    ///
115    /// # Errors
116    ///
117    /// Returns an error string if the file can't be opened (missing,
118    /// unreadable, unsupported codec). The error is surfaced to the
119    /// webview so the UI can show a toast.
120    pub fn open(&self, path: &Path) -> Result<PlayerStatus, String> {
121        let stream = GstreamerPipeStream::open(path).map_err(|e| e.to_string())?;
122        let width = stream.width();
123        let height = stream.height();
124        let fps = stream.frame_rate();
125        let frame_count_hint = stream.frame_count_hint();
126        let player = Player::new(&self.app, Box::new(stream));
127
128        let mut guard = self.inner.lock().expect("PlayerSession poisoned");
129        *guard = Some(Inner {
130            player,
131            last_tick: Instant::now(),
132            width,
133            height,
134            fps,
135            frame_count_hint,
136        });
137        Ok(status_of(guard.as_ref()))
138    }
139
140    /// Transition to [`SessionState::Playing`]. No-op when empty or
141    /// already playing.
142    pub fn play(&self) {
143        let mut guard = self.inner.lock().expect("PlayerSession poisoned");
144        if let Some(inner) = guard.as_mut() {
145            inner.player.play();
146            inner.last_tick = Instant::now();
147        }
148    }
149
150    /// Transition to [`SessionState::Paused`]. No-op when empty.
151    pub fn pause(&self) {
152        let mut guard = self.inner.lock().expect("PlayerSession poisoned");
153        if let Some(inner) = guard.as_mut() {
154            inner.player.pause();
155        }
156    }
157
158    /// Drive the player by the wallclock delta since the last `tick`.
159    ///
160    /// Returns the number of frames the player uploaded. `0` when the
161    /// session is empty, paused, ended, or no new frame was due.
162    pub fn tick(&self) -> u32 {
163        let mut guard = self.inner.lock().expect("PlayerSession poisoned");
164        let Some(inner) = guard.as_mut() else {
165            return 0;
166        };
167        let now = Instant::now();
168        let dt = now.duration_since(inner.last_tick);
169        inner.last_tick = now;
170        inner.player.tick(&self.app, dt)
171    }
172
173    /// Snapshot of the current status. Cheap — no decoding work.
174    #[must_use]
175    pub fn status(&self) -> PlayerStatus {
176        let guard = self.inner.lock().expect("PlayerSession poisoned");
177        status_of(guard.as_ref())
178    }
179}
180
181impl Default for PlayerSession {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187fn status_of(inner: Option<&Inner>) -> PlayerStatus {
188    let Some(inner) = inner else {
189        return PlayerStatus::empty();
190    };
191    let state = match inner.player.state() {
192        PlayState::Paused => SessionState::Paused,
193        PlayState::Playing => SessionState::Playing,
194        PlayState::Ended => SessionState::Ended,
195    };
196    PlayerStatus {
197        state,
198        elapsed_ms: ms_from_duration(inner.player.elapsed()),
199        duration_ms: inner.player.duration_hint().map(ms_from_duration),
200        width: inner.width,
201        height: inner.height,
202        fps: inner.fps,
203        frame_count: inner.frame_count_hint,
204    }
205}
206
207fn ms_from_duration(d: Duration) -> u64 {
208    u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
209}