jellytau_lib/player/media_player.rs
1//! The `MediaPlayer` contract: one API, interchangeable engines.
2//!
3//! See docs/specs/media-player-controller.md.
4//!
5//! This replaces [`PlayerBackend`](super::backend::PlayerBackend), which
6//! abstracts a *device* — `load`, then `seek` — rather than an *intent*. That
7//! distinction is not academic; it produced four shipped defects in one day:
8//!
9//! * A start position was not expressible, so every caller sequenced
10//! `load()` + `seek()` itself and each raced the engine's asynchronous load
11//! independently. Resume worked through one caller and silently failed through
12//! another (DR-241).
13//! * Whether a stream could be seeked in place was decided *above* the engines,
14//! by a truth table in a command handler, for engines it does not own (DR-238).
15//! * Nothing in the contract obliged an engine to report its own state, so a
16//! handler for mpv's `pause` property sat unreachable and the play/pause
17//! control never moved (DR-239).
18//!
19//! The contract below is written so each of those is a compile-time or
20//! conformance-time failure rather than a runtime surprise.
21//!
22//! TRACES: UR-081 | DR-242
23
24// Scaffolding: nothing consumes this contract until `PlayerController` is
25// ported to it (DR-245). Kept out of `cfg(test)` deliberately — it is production
26// code being built in shippable steps, not a test fixture. Remove this allow
27// when the controller talks to `MediaPlayer`.
28#![allow(dead_code)]
29
30use std::time::Duration;
31
32use super::backend::PlayerError;
33use super::media::MediaItem;
34use crate::repository::stream_selection::StreamSelection;
35use crate::settings::AudioSettings;
36
37/// Seconds reported by an engine, as a `Duration`, without trusting the number.
38///
39/// `Duration::from_secs_f64` **panics** on a negative or non-finite value, and
40/// no engine promises otherwise. ExoPlayer reports `C.TIME_UNSET` —
41/// `Long::MIN_VALUE`, about -9.2e15 — for a stream whose length it does not
42/// know, which is every background-audio handoff: `/Audio/{id}/universal` is a
43/// chunked, length-less transcode.
44///
45/// Held as a float that junk was harmless. Converted to a `Duration` it became
46/// a panic that killed the backend mid-handoff and left a black screen with no
47/// controls. Every engine crossing into this contract goes through here.
48///
49/// TRACES: UR-005 | DR-252
50pub fn duration_from_secs(seconds: f64) -> Option<Duration> {
51 (seconds.is_finite() && seconds > 0.0).then(|| Duration::from_secs_f64(seconds))
52}
53
54/// What an engine is doing right now.
55///
56/// `Opening` is the state the previous design could not express, and is the
57/// direct cause of DR-241: a seek that arrived while the engine had nothing
58/// loaded had no phase to be queued against, so it was simply discarded and
59/// playback began at zero.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum Phase {
62 /// Nothing loaded. `close()` must reach this, and must be silent here.
63 Idle,
64 /// An `open` is in flight. Position is not yet meaningful; a `seek` arriving
65 /// now must be honoured once the engine reaches `Ready`, never dropped.
66 Opening,
67 /// Loaded and able to play, but not advancing.
68 Ready,
69 Playing,
70 Paused,
71 /// Reached the end of the item by itself. Distinct from `Idle`, because
72 /// autoplay cares which one happened.
73 Ended,
74 Failed(String),
75}
76
77impl Phase {
78 /// Whether the engine currently holds an item.
79 pub fn has_media(&self) -> bool {
80 !matches!(self, Phase::Idle | Phase::Failed(_))
81 }
82
83 /// Whether playback is advancing.
84 pub fn is_active(&self) -> bool {
85 matches!(self, Phase::Playing)
86 }
87}
88
89/// Everything the UI consumes, read as one coherent value.
90///
91/// Deliberately a single snapshot rather than a dozen getters: reading position
92/// and duration through separate calls is how a paused player reported
93/// `<position> / 0.0` when a file unloaded between them.
94#[derive(Debug, Clone)]
95pub struct PlaybackSnapshot {
96 pub phase: Phase,
97 pub position: Duration,
98 /// `None` while unknown — a live stream, or an item still opening.
99 pub duration: Option<Duration>,
100 /// Whether `seek` can be expected to land. False for live edges.
101 pub seekable: bool,
102 /// 0.0 – 1.0.
103 pub volume: f32,
104 pub muted: bool,
105 pub rate: f64,
106 pub audio_track: Option<i32>,
107 pub subtitle_track: Option<i32>,
108}
109
110impl Default for PlaybackSnapshot {
111 fn default() -> Self {
112 Self {
113 phase: Phase::Idle,
114 position: Duration::ZERO,
115 duration: None,
116 seekable: false,
117 volume: 1.0,
118 muted: false,
119 rate: 1.0,
120 audio_track: None,
121 subtitle_track: None,
122 }
123 }
124}
125
126/// What an engine can do, so callers adapt without naming engines.
127///
128/// If a caller ever branches on *which* engine it holds, this struct is missing
129/// something — add it here rather than sniffing. Engine identity leaking into
130/// callers is the coupling DR-238 came from.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub struct Capabilities {
133 /// The engine renders pictures, not only sound.
134 pub video: bool,
135 /// Audio settings (EQ, normalisation, gapless) are honoured.
136 pub audio_settings: bool,
137 /// Subtitle tracks can be selected without re-opening.
138 pub subtitle_switching: bool,
139 /// Audio tracks can be selected without re-opening.
140 pub audio_track_switching: bool,
141 /// A *server-side transcode* can be seeked without re-opening the stream.
142 ///
143 /// True for hls.js, which seeks within the VOD playlist it is handed and
144 /// lets the server catch up. False for mpv, whose HLS demuxer cannot make
145 /// the server transcode from a new offset.
146 ///
147 /// Declared by the engine rather than inferred by the caller. The previous
148 /// design decided this from `is_hls` and `use_html5` in a command handler —
149 /// on behalf of engines it did not own — which is how "who renders" came to
150 /// mean "how do I seek" and why a transcoded seek silently did nothing the
151 /// moment native video changed the renderer (DR-238).
152 ///
153 /// Re-negotiating a stream needs the repository, which sits above the
154 /// engine, so the engine states the capability and the caller acts on it.
155 pub seeks_transcoded_in_place: bool,
156}
157
158impl Capabilities {
159 /// mpv.
160 ///
161 /// Cannot seek a server-side transcode in place: its HLS demuxer will not
162 /// make the server produce segments from a new offset, so the stream has to
163 /// be re-opened.
164 pub fn mpv() -> Self {
165 Self {
166 video: true,
167 audio_settings: true,
168 subtitle_switching: true,
169 audio_track_switching: true,
170 seeks_transcoded_in_place: false,
171 }
172 }
173
174 /// ExoPlayer.
175 ///
176 /// **Can** seek a transcode in place. It is a full HLS client, so like
177 /// hls.js it seeks within the VOD playlist it was handed and lets the
178 /// server catch up. Grouping it with mpv as "a native engine" gets this
179 /// exactly backwards — being native is not the property that matters here,
180 /// speaking HLS is, and that is the whole reason this is declared per
181 /// engine rather than inferred from a category.
182 pub fn exoplayer() -> Self {
183 Self {
184 video: true,
185 audio_settings: true,
186 subtitle_switching: true,
187 audio_track_switching: true,
188 seeks_transcoded_in_place: true,
189 }
190 }
191
192 /// An engine that renders through the webview element, where hls.js seeks
193 /// within the playlist it was handed.
194 pub fn webview() -> Self {
195 Self {
196 video: true,
197 audio_settings: false,
198 subtitle_switching: true,
199 audio_track_switching: false,
200 seeks_transcoded_in_place: true,
201 }
202 }
203}
204
205/// A request to present an item.
206///
207/// `start` is the reason this type exists. Carrying it here — rather than
208/// leaving callers to `seek` after `open` — is what closes the load/seek race,
209/// because the engine is the only layer that knows when its pipeline can accept
210/// a position.
211#[derive(Debug, Clone)]
212pub struct OpenRequest {
213 pub media: MediaItem,
214 pub selection: StreamSelection,
215 /// Where to begin. `Duration::ZERO` means the start of the item.
216 pub start: Duration,
217 pub audio_track: Option<i32>,
218 pub subtitle_track: Option<i32>,
219 /// Begin playing as soon as the engine is able.
220 pub autoplay: bool,
221}
222
223impl OpenRequest {
224 /// Open at the beginning, playing.
225 pub fn new(media: MediaItem, selection: StreamSelection) -> Self {
226 Self {
227 media,
228 selection,
229 start: Duration::ZERO,
230 audio_track: None,
231 subtitle_track: None,
232 autoplay: true,
233 }
234 }
235
236 pub fn starting_at(mut self, start: Duration) -> Self {
237 self.start = start;
238 self
239 }
240}
241
242/// Anything that can present media.
243///
244/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
245/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
246/// them must pass [`super::conformance`].
247pub trait MediaPlayer: Send {
248 /// Present `req.selection`, beginning at `req.start`.
249 ///
250 /// One operation, deliberately. An engine that cannot start at an offset
251 /// natively absorbs that internally — by deferring until loaded, or by
252 /// re-opening — because it is the only layer that knows when it can.
253 /// Callers must never follow `open` with a `seek` to achieve a start
254 /// position; that is the bug this signature exists to prevent.
255 fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
256
257 fn play(&mut self) -> Result<(), PlayerError>;
258 fn pause(&mut self) -> Result<(), PlayerError>;
259
260 /// Stop and release the current item.
261 ///
262 /// Must be **idempotent** and must leave the engine **silent**. "Stopped"
263 /// and "producing no audio" were not the same thing in the previous design,
264 /// and the gap between them is audible.
265 fn close(&mut self) -> Result<(), PlayerError>;
266
267 /// Seek to an absolute position on the item's own timeline.
268 ///
269 /// Whether that is an in-place seek or a re-open of the stream is the
270 /// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
271 /// cannot make a server transcode from a new offset. Callers state the
272 /// destination and nothing else.
273 fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
274
275 fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
276 fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError>;
277 fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
278
279 fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
280 fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
281
282 /// One coherent read of the engine's state.
283 fn snapshot(&self) -> PlaybackSnapshot;
284
285 fn capabilities(&self) -> Capabilities;
286
287 /// Apply EQ, normalisation and gapless settings.
288 ///
289 /// Provided rather than required: engines that cannot honour them say so
290 /// through [`Capabilities::audio_settings`] and inherit this no-op, instead
291 /// of every implementation carrying an `Ok(())` it does not mean.
292 fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
293 Ok(())
294 }
295
296 fn audio_settings(&self) -> AudioSettings {
297 AudioSettings::default()
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 /// The value that killed the backend: `C.TIME_UNSET` as seconds.
306 ///
307 /// ExoPlayer reports it for any stream whose length it does not know, and
308 /// `Duration::from_secs_f64` panics on it. A player must not be the place
309 /// anyone discovers a float was strange.
310 ///
311 /// TRACES: UR-005 | DR-252 | UT-222
312 #[test]
313 fn test_junk_durations_do_not_panic() {
314 // Long::MIN_VALUE milliseconds, as ExoPlayer hands it over.
315 assert_eq!(duration_from_secs(-9_223_372_036_854_776.0), None);
316 assert_eq!(duration_from_secs(-1.0), None);
317 assert_eq!(duration_from_secs(0.0), None, "zero is not a duration");
318 assert_eq!(duration_from_secs(f64::NAN), None);
319 assert_eq!(duration_from_secs(f64::INFINITY), None);
320 assert_eq!(duration_from_secs(f64::NEG_INFINITY), None);
321
322 // A real one still survives.
323 assert_eq!(
324 duration_from_secs(6997.024),
325 Some(Duration::from_secs_f64(6997.024))
326 );
327 }
328}