//! A deterministic in-memory [`MediaPlayer`], for tests. //! //! Two jobs: //! //! 1. Give the conformance suite something that is correct by construction, so a //! failure there means the *suite* is wrong rather than an engine. //! 2. Let everything above the engine — controller, queue, autoplay, sleep //! timer, session — be tested with no mpv, no device and no network. Most of //! that logic is currently only reachable through a real engine, which is why //! so little of it is covered. //! //! It models the one behaviour that matters most: **opening is not //! instantaneous**. `open()` lands in [`Phase::Opening`] and stays there until //! [`FakePlayer::complete_open`] is called, so a test can put a `seek` into that //! window on purpose. That is the window DR-241 lived in. //! //! TRACES: UR-081 | DR-243 // `tick` and `fail_open` are for tests not yet written — the controller-level // ones DR-245 unlocks. Remove this allow once those exist. #![allow(dead_code)] use std::time::Duration; use super::backend::PlayerError; use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot}; #[derive(Debug, Clone, PartialEq)] pub enum FakeEvent { Opened { url: String, start: Duration }, Played, Paused, Closed, Sought(Duration), } pub struct FakePlayer { snapshot: PlaybackSnapshot, /// Set while `Opening`; applied when the open completes. pending_start: Duration, /// A seek that arrived while opening. Honoured on completion, never dropped. deferred_seek: Option, autoplay: bool, duration: Duration, /// Every call, in order — so tests can assert what an engine was *asked* to /// do, not only where it ended up. pub log: Vec, /// Whether audio is being produced. `close()` must clear it; the bug that /// motivated all this had a "stopped" player that was still audible. pub audible: bool, pub capabilities: Capabilities, } impl Default for FakePlayer { fn default() -> Self { Self::new() } } impl FakePlayer { pub fn new() -> Self { Self { snapshot: PlaybackSnapshot::default(), pending_start: Duration::ZERO, deferred_seek: None, autoplay: true, duration: Duration::from_secs(3600), log: Vec::new(), audible: false, capabilities: Capabilities { video: true, audio_settings: true, subtitle_switching: true, audio_track_switching: true, // The fake honours a seek in any phase, so it can claim this. seeks_transcoded_in_place: true, }, } } /// The item this fake will report once opened. pub fn with_duration(mut self, duration: Duration) -> Self { self.duration = duration; self } /// Finish an in-flight `open`, as a real engine's "file loaded" would. /// /// Applies the requested start position, then any seek that arrived while /// opening — the later intent wins. pub fn complete_open(&mut self) { if self.snapshot.phase != Phase::Opening { return; } self.snapshot.duration = Some(self.duration); self.snapshot.seekable = true; self.snapshot.position = self.deferred_seek.take().unwrap_or(self.pending_start); if self.autoplay { self.snapshot.phase = Phase::Playing; self.audible = true; } else { self.snapshot.phase = Phase::Ready; } } /// Advance playback, for tests that care about time passing. pub fn tick(&mut self, by: Duration) { if self.snapshot.phase.is_active() { self.snapshot.position = (self.snapshot.position + by).min(self.duration); if self.snapshot.position >= self.duration { self.snapshot.phase = Phase::Ended; self.audible = false; } } } pub fn fail_open(&mut self, why: &str) { self.snapshot.phase = Phase::Failed(why.to_string()); self.audible = false; } } impl MediaPlayer for FakePlayer { fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> { self.log.push(FakeEvent::Opened { url: req.selection.url.clone(), start: req.start, }); self.snapshot = PlaybackSnapshot { phase: Phase::Opening, volume: self.snapshot.volume, muted: self.snapshot.muted, rate: self.snapshot.rate, audio_track: req.audio_track, subtitle_track: req.subtitle_track, ..PlaybackSnapshot::default() }; self.pending_start = req.start; self.deferred_seek = None; self.autoplay = req.autoplay; self.audible = false; Ok(()) } fn play(&mut self) -> Result<(), PlayerError> { self.log.push(FakeEvent::Played); if self.snapshot.phase.has_media() { if self.snapshot.phase == Phase::Opening { self.autoplay = true; } else { self.snapshot.phase = Phase::Playing; self.audible = true; } } Ok(()) } fn pause(&mut self) -> Result<(), PlayerError> { self.log.push(FakeEvent::Paused); if self.snapshot.phase == Phase::Opening { self.autoplay = false; } else if self.snapshot.phase.has_media() { self.snapshot.phase = Phase::Paused; self.audible = false; } Ok(()) } fn close(&mut self) -> Result<(), PlayerError> { self.log.push(FakeEvent::Closed); self.snapshot = PlaybackSnapshot { volume: self.snapshot.volume, muted: self.snapshot.muted, rate: self.snapshot.rate, ..PlaybackSnapshot::default() }; self.pending_start = Duration::ZERO; self.deferred_seek = None; // An open that was still in flight must not come back to life. self.autoplay = false; self.audible = false; Ok(()) } fn seek(&mut self, to: Duration) -> Result<(), PlayerError> { self.log.push(FakeEvent::Sought(to)); match self.snapshot.phase { // The window DR-241 lived in: hold it, do not discard it. Phase::Opening => self.deferred_seek = Some(to), Phase::Idle | Phase::Failed(_) => { return Err(PlayerError { message: "seek with nothing open".to_string(), }) } _ => self.snapshot.position = to.min(self.duration), } Ok(()) } fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> { self.snapshot.volume = volume.clamp(0.0, 1.0); Ok(()) } fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> { self.snapshot.muted = muted; Ok(()) } fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> { self.snapshot.rate = rate; Ok(()) } fn select_audio_track(&mut self, index: Option) -> Result<(), PlayerError> { self.snapshot.audio_track = index; Ok(()) } fn select_subtitle_track(&mut self, index: Option) -> Result<(), PlayerError> { self.snapshot.subtitle_track = index; Ok(()) } fn snapshot(&self) -> PlaybackSnapshot { self.snapshot.clone() } fn capabilities(&self) -> Capabilities { self.capabilities } }