Skip to main content

#
jellytau_lib/player/
legacy_player.rs

    1//! A [`MediaPlayer`] over the old [`PlayerBackend`] trait.
    2//!
    3//! Two purposes.
    4//!
    5//! **Migration.** Engines not yet ported — ExoPlayer, the webview element, the
    6//! null backend — keep working while `PlayerController` moves onto the new
    7//! contract (DR-245). Without this the port would have to land all four engines
    8//! at once.
    9//!
    10//! **Evidence.** It reproduces exactly what every caller used to do: `load`,
    11//! then `play`, then `seek` for a start position. Running the conformance suite
    12//! against it therefore shows the old path failing the cases the new one passes,
    13//! on the same engine and the same media — which is the difference between
    14//! asserting that a design was wrong and demonstrating it.
    15//!
    16//! It is deliberately a faithful reproduction, not a fixed-up one. Making it
    17//! pass would defeat the point.
    18//!
    19//! TRACES: UR-081 | DR-245
    20
    21use std::time::Duration;
    22
    23use super::backend::{PlayerBackend, PlayerError};
    24use super::media_player::{
    25    duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
    26};
    27use super::state::PlayerState;
    28
    29pub struct LegacyPlayer<B: PlayerBackend> {
    30    inner: B,
    31    /// Declared at construction: this wrapper is generic over engines with very
    32    /// different abilities, and only the composition root knows which one it
    33    /// just built. Guessing here would reintroduce exactly the inference DR-238
    34    /// removed.
    35    capabilities: Capabilities,
    36    /// The old trait has no notion of "opening", so this is the best the wrapper
    37    /// can do: it knows an item was handed over, not whether the engine is ready
    38    /// for one. That gap is the whole problem.
    39    has_item: bool,
    40}
    41
    42impl<B: PlayerBackend> LegacyPlayer<B> {
    43    pub fn new(inner: B, capabilities: Capabilities) -> Self {
    44        Self {
    45            inner,
    46            capabilities,
    47            has_item: false,
    48        }
    49    }
    50}
    51
    52impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
    53    /// Load, play, then seek — the sequence every caller used to write.
    54    ///
    55    /// The seek is issued immediately, because a caller has no way to know when
    56    /// the engine becomes ready. On an engine whose load is asynchronous it
    57    /// fails and is discarded, and playback begins at zero: DR-241, reproduced.
    58    fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
    59        self.inner.load(&req.media)?;
    60        self.has_item = true;
    61        if req.autoplay {
    62            self.inner.play()?;
    63        }
    64        if !req.start.is_zero() {
    65            // Faithfully ignoring the failure, exactly as the old callers did.
    66            let _ = self.inner.seek(req.start.as_secs_f64());
    67        }
    68        Ok(())
    69    }
    70
    71    fn play(&mut self) -> Result<(), PlayerError> {
    72        self.inner.play()
    73    }
    74
    75    fn pause(&mut self) -> Result<(), PlayerError> {
    76        self.inner.pause()
    77    }
    78
    79    fn close(&mut self) -> Result<(), PlayerError> {
    80        self.has_item = false;
    81        self.inner.stop()
    82    }
    83
    84    fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
    85        self.inner.seek(to.as_secs_f64())
    86    }
    87
    88    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
    89        self.inner.set_volume(volume)
    90    }
    91
    92    /// The old trait has no mute. Folding it into volume would lose the user's
    93    /// level, so this reports unsupported rather than pretending.
    94    fn set_muted(&mut self, _muted: bool) -> Result<(), PlayerError> {
    95        Err(PlayerError {
    96            message: "mute is not supported by this backend".to_string(),
    97        })
    98    }
    99
    100    fn set_rate(&mut self, _rate: f64) -> Result<(), PlayerError> {
    101        Err(PlayerError {
    102            message: "playback rate is not supported by this backend".to_string(),
    103        })
    104    }
    105
    106    fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
    107        self.inner.set_audio_track(index.unwrap_or(-1))
    108    }
    109
    110    fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
    111        self.inner.set_subtitle_track(index)
    112    }
    113
    114    fn snapshot(&self) -> PlaybackSnapshot {
    115        let phase = match self.inner.state() {
    116            _ if !self.has_item => Phase::Idle,
    117            PlayerState::Playing { .. } => Phase::Playing,
    118            PlayerState::Paused { .. } => Phase::Paused,
    119            PlayerState::Idle => Phase::Idle,
    120            PlayerState::Error { error, .. } => Phase::Failed(error),
    121            // `Loading` is the closest the old trait comes to an opening state,
    122            // but it is set once the engine has accepted the item rather than
    123            // while it is still accepting it — which is precisely the window it
    124            // cannot describe.
    125            PlayerState::Loading { .. } | PlayerState::Seeking { .. } => Phase::Ready,
    126        };
    127        PlaybackSnapshot {
    128            phase,
    129            position: duration_from_secs(self.inner.position()).unwrap_or(Duration::ZERO),
    130            duration: self.inner.duration().and_then(duration_from_secs),
    131            seekable: true,
    132            volume: self.inner.volume(),
    133            muted: false,
    134            rate: 1.0,
    135            audio_track: None,
    136            subtitle_track: None,
    137        }
    138    }
    139
    140    fn set_audio_settings(
    141        &mut self,
    142        settings: &crate::settings::AudioSettings,
    143    ) -> Result<(), PlayerError> {
    144        self.inner.set_audio_settings(settings)
    145    }
    146
    147    fn audio_settings(&self) -> crate::settings::AudioSettings {
    148        self.inner.audio_settings()
    149    }
    150
    151    fn capabilities(&self) -> Capabilities {
    152        self.capabilities
    153    }
    154}
    155
    156#[cfg(test)]
    157mod tests {
    158    use super::*;
    159    use crate::player::media::MediaItem;
    160    use crate::settings::AudioSettings;
    161
    162    /// A backend that answers badly, on purpose.
    163    ///
    164    /// Every engine the conformance suite drives reports sane numbers, which is
    165    /// why it passed while a real one did not: ExoPlayer returns
    166    /// `C.TIME_UNSET` — `Long::MIN_VALUE`, about -9.2e15 seconds — for any
    167    /// stream whose length it does not know, and the adapter converted that
    168    /// straight into a `Duration` and panicked the whole backend.
    169    ///
    170    /// The old `PlayerBackend` contract is a plain `f64`. It never promised
    171    /// finite, never promised positive, and nothing enforced it. So this is the
    172    /// engine the suites were missing.
    173    struct HostileBackend {
    174        duration: f64,
    175        position: f64,
    176    }
    177
    178    impl PlayerBackend for HostileBackend {
    179        fn load(&mut self, _media: &MediaItem) -> Result<(), PlayerError> {
    180            Ok(())
    181        }
    182        fn play(&mut self) -> Result<(), PlayerError> {
    183            Ok(())
    184        }
    185        fn pause(&mut self) -> Result<(), PlayerError> {
    186            Ok(())
    187        }
    188        fn stop(&mut self) -> Result<(), PlayerError> {
    189            Ok(())
    190        }
    191        fn seek(&mut self, _position: f64) -> Result<(), PlayerError> {
    192            Ok(())
    193        }
    194        fn set_volume(&mut self, _volume: f32) -> Result<(), PlayerError> {
    195            Ok(())
    196        }
    197        fn position(&self) -> f64 {
    198            self.position
    199        }
    200        fn duration(&self) -> Option<f64> {
    201            Some(self.duration)
    202        }
    203        fn state(&self) -> PlayerState {
    204            PlayerState::Idle
    205        }
    206        fn volume(&self) -> f32 {
    207            1.0
    208        }
    209        fn set_audio_settings(&mut self, _s: &AudioSettings) -> Result<(), PlayerError> {
    210            Ok(())
    211        }
    212        fn audio_settings(&self) -> AudioSettings {
    213            AudioSettings::default()
    214        }
    215        fn set_audio_track(&mut self, _i: i32) -> Result<(), PlayerError> {
    216            Ok(())
    217        }
    218        fn set_subtitle_track(&mut self, _i: Option<i32>) -> Result<(), PlayerError> {
    219            Ok(())
    220        }
    221    }
    222
    223    fn hostile(duration: f64, position: f64) -> LegacyPlayer<HostileBackend> {
    224        LegacyPlayer::new(
    225            HostileBackend { duration, position },
    226            crate::player::media_player::Capabilities::mpv(),
    227        )
    228    }
    229
    230    /// Reading an engine that answers badly must not take the process down.
    231    ///
    232    /// This is DR-252 as a test. It fails — by panicking — against the adapter
    233    /// as originally written, which is the property the conformance suite could
    234    /// not have: it only ever drove engines that behave.
    235    ///
    236    /// TRACES: UR-005 | DR-252 | UT-223
    237    #[test]
    238    fn test_snapshot_survives_an_engine_that_answers_badly() {
    239        // The exact value ExoPlayer reports for an unknown length.
    240        let s = hostile(-9_223_372_036_854_776.0, 0.0).snapshot();
    241        assert_eq!(s.duration, None, "a negative duration is not a duration");
    242
    243        for bad in [f64::NAN, f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0] {
    244            let s = hostile(bad, bad).snapshot();
    245            assert_eq!(s.duration, None, "{bad} should not become a duration");
    246            assert_eq!(
    247                s.position,
    248                Duration::ZERO,
    249                "{bad} should not become a position"
    250            );
    251        }
    252
    253        // And a well-behaved engine still works.
    254        let s = hostile(6997.024, 540.0).snapshot();
    255        assert_eq!(s.duration, Some(Duration::from_secs_f64(6997.024)));
    256        assert_eq!(s.position, Duration::from_secs_f64(540.0));
    257    }
    258}