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}