Skip to main content

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}