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}