//! [`MediaPlayer`] over libmpv. //! //! The point of difference from `MpvBackend` is [`MpvPlayer::open`]: the start //! position is applied **at load time**, via mpv's own `start` option, instead //! of being seeked to afterwards. `loadfile` is asynchronous, so a seek issued //! after it targets a player that has nothing loaded, fails, and — under the old //! contract — was discarded. That is DR-241, and it is why resume and transcoded //! skip both played from zero. //! //! A seek arriving during [`Phase::Opening`] is held and applied when the file //! loads, so no caller has to know where that window begins or ends. //! //! TRACES: UR-081, UR-040, UR-005 | DR-244 #![allow(dead_code)] // Wired to PlayerController in DR-245. use std::sync::{Arc, Mutex}; use std::time::Duration; use libmpv::Mpv; use log::{debug, info, warn}; use super::backend::PlayerError; use super::media_player::{ duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot, }; use crate::utils::lock::MutexSafe; /// State the event thread writes and the caller reads. #[derive(Debug)] struct Shared { phase: Phase, position: Duration, duration: Option, seekable: bool, /// A seek that arrived while opening. Applied on `FileLoaded`. deferred_seek: Option, /// Cleared by `close()`, so an open still in flight cannot come back to life /// and start playing after the caller has stopped it. open_generation: u64, } impl Default for Shared { fn default() -> Self { Self { phase: Phase::Idle, position: Duration::ZERO, duration: None, seekable: false, deferred_seek: None, open_generation: 0, } } } pub struct MpvPlayer { mpv: Arc, shared: Arc>, volume: f32, muted: bool, rate: f64, audio_track: Option, subtitle_track: Option, } /// How the engine should talk to the machine. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Output { /// Real audio and video. What the app uses. Real, /// No audio device, no window. What conformance uses, so the suite can run /// on a headless runner without claiming the user's speakers. Null, } impl MpvPlayer { pub fn new(output: Output) -> Result { // mpv refuses to start under a non-C LC_NUMERIC, and anything that has // initialised GTK before us will have set one. unsafe { let c = std::ffi::CString::new("C").unwrap(); libc::setlocale(libc::LC_NUMERIC, c.as_ptr()); } let mpv = Mpv::new().map_err(|e| PlayerError { message: format!("mpv_create failed: {e:?}"), })?; let set = |k: &str, v: &str| { if let Err(e) = mpv.set_property(k, v) { warn!("[MpvPlayer] could not set {k}={v}: {e:?}"); } }; match output { Output::Real => { set("vo", "libmpv"); } Output::Null => { set("ao", "null"); set("vo", "null"); } } set("msg-level", "all=warn"); // Survive a blip rather than ending the item on it. set( "stream-lavf-o", "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5", ); let player = Self { mpv: Arc::new(mpv), shared: Arc::new(Mutex::new(Shared::default())), volume: 1.0, muted: false, rate: 1.0, audio_track: None, subtitle_track: None, }; player.spawn_events(); Ok(player) } fn spawn_events(&self) { let mpv = self.mpv.clone(); let shared = self.shared.clone(); std::thread::spawn(move || { let mut ev = mpv.create_event_context(); let _ = ev.disable_deprecated_events(); // Every property matched below must be observed, or libmpv never // delivers it and the handler is unreachable (DR-239). for prop in ["pause", "eof-reached"] { if let Err(e) = ev.observe_property(prop, libmpv::Format::Flag, 0) { warn!("[MpvPlayer] could not observe {prop}: {e:?}"); } } loop { match ev.wait_event(0.25) { Some(Ok(libmpv::events::Event::FileLoaded)) => { let deferred = { let mut s = shared.lock_safe(); // Closed while opening: do not start. if s.phase == Phase::Idle { continue; } s.duration = mpv .get_property::("duration") .ok() .and_then(duration_from_secs); s.seekable = mpv.get_property::("seekable").unwrap_or(true); s.phase = Phase::Playing; s.deferred_seek.take() }; if let Some(to) = deferred { debug!("[MpvPlayer] applying deferred seek to {to:?}"); if let Err(e) = mpv.set_property("time-pos", to.as_secs_f64()) { warn!("[MpvPlayer] deferred seek failed: {e:?}"); } } } Some(Ok(libmpv::events::Event::PropertyChange { name: "pause", .. })) => { if let Ok(paused) = mpv.get_property::("pause") { let mut s = shared.lock_safe(); if s.phase.has_media() { s.phase = if paused { Phase::Paused } else { Phase::Playing }; } } } Some(Ok(libmpv::events::Event::EndFile(reason))) => { let mut s = shared.lock_safe(); // 0 = EOF. Anything else is a stop, a quit or an error, // and must not read as "the item finished". s.phase = if reason == 0 { Phase::Ended } else { Phase::Idle }; } Some(Ok(libmpv::events::Event::Shutdown)) => break, _ => {} } if let Ok(pos) = mpv.get_property::("time-pos") { let mut s = shared.lock_safe(); if s.phase.has_media() && s.deferred_seek.is_none() { s.position = Duration::from_secs_f64(pos.max(0.0)); } } } }); } } impl MediaPlayer for MpvPlayer { fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> { { let mut s = self.shared.lock_safe(); *s = Shared { phase: Phase::Opening, open_generation: s.open_generation + 1, ..Shared::default() }; // Report the requested position immediately, so a caller reading // back during the open sees where it asked to be rather than zero. s.position = req.start; } // The whole point. `start` is applied by mpv as it opens the file, so // there is no window in which the position can be asked for and lost. let start = if req.start.is_zero() { "none".to_string() } else { format!("{:.3}", req.start.as_secs_f64()) }; self.mpv .set_property("start", start.as_str()) .map_err(|e| PlayerError { message: format!("could not set start position: {e:?}"), })?; self.mpv .set_property("pause", !req.autoplay) .map_err(|e| PlayerError { message: format!("could not set pause: {e:?}"), })?; info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start); self.mpv .command("loadfile", &[&req.selection.url, "replace"]) .map_err(|e| PlayerError { message: format!("loadfile failed: {e:?}"), })?; Ok(()) } fn play(&mut self) -> Result<(), PlayerError> { self.mpv .set_property("pause", false) .map_err(|e| PlayerError { message: format!("play failed: {e:?}"), })?; let mut s = self.shared.lock_safe(); if s.phase.has_media() && s.phase != Phase::Opening { s.phase = Phase::Playing; } Ok(()) } fn pause(&mut self) -> Result<(), PlayerError> { self.mpv .set_property("pause", true) .map_err(|e| PlayerError { message: format!("pause failed: {e:?}"), })?; let mut s = self.shared.lock_safe(); if s.phase.has_media() && s.phase != Phase::Opening { s.phase = Phase::Paused; } Ok(()) } fn close(&mut self) -> Result<(), PlayerError> { // State first: an open still in flight checks this on FileLoaded and // must not proceed to play after the caller has stopped it. { let mut s = self.shared.lock_safe(); *s = Shared { open_generation: s.open_generation, ..Shared::default() }; } // Idempotent: stopping an already-stopped mpv is not an error worth // propagating, and callers legitimately close twice on teardown. if let Err(e) = self.mpv.command("stop", &[]) { debug!("[MpvPlayer] stop on an idle player: {e:?}"); } Ok(()) } fn seek(&mut self, to: Duration) -> Result<(), PlayerError> { { let mut s = self.shared.lock_safe(); match s.phase { // Held, not dropped. The caller cannot see this window. Phase::Opening => { s.deferred_seek = Some(to); s.position = to; return Ok(()); } Phase::Idle | Phase::Failed(_) => { return Err(PlayerError { message: "seek with nothing open".to_string(), }) } _ => s.position = to, } } self.mpv .set_property("time-pos", to.as_secs_f64()) .map_err(|e| PlayerError { message: format!("seek failed: {e:?}"), }) } fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> { let clamped = volume.clamp(0.0, 1.0); self.volume = clamped; self.mpv .set_property("volume", (clamped as f64) * 100.0) .map_err(|e| PlayerError { message: format!("set_volume failed: {e:?}"), }) } fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> { self.muted = muted; self.mpv .set_property("mute", muted) .map_err(|e| PlayerError { message: format!("set_muted failed: {e:?}"), }) } fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> { self.rate = rate; self.mpv .set_property("speed", rate) .map_err(|e| PlayerError { message: format!("set_rate failed: {e:?}"), }) } fn select_audio_track(&mut self, index: Option) -> Result<(), PlayerError> { self.audio_track = index; let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into()); self.mpv .set_property("aid", value.as_str()) .map_err(|e| PlayerError { message: format!("select_audio_track failed: {e:?}"), }) } fn select_subtitle_track(&mut self, index: Option) -> Result<(), PlayerError> { self.subtitle_track = index; let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into()); self.mpv .set_property("sid", value.as_str()) .map_err(|e| PlayerError { message: format!("select_subtitle_track failed: {e:?}"), }) } fn snapshot(&self) -> PlaybackSnapshot { let s = self.shared.lock_safe(); PlaybackSnapshot { phase: s.phase.clone(), position: s.position, duration: s.duration, seekable: s.seekable, volume: self.volume, muted: self.muted, rate: self.rate, audio_track: self.audio_track, subtitle_track: self.subtitle_track, } } fn capabilities(&self) -> Capabilities { Capabilities { video: true, audio_settings: true, subtitle_switching: true, audio_track_switching: true, // mpv's HLS demuxer cannot make the server transcode from a new // offset, so a transcoded seek must re-open the stream. seeks_transcoded_in_place: false, } } }