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}