diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7d7d1e5d..9c60ae2c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -141,3 +141,18 @@ ndk-context = "0.1" [dev-dependencies] tempfile = "3.24.0" +[features] +# Exposes the MediaPlayer conformance suite and the `player-conformance` binary +# to non-test builds, so an engine that cannot run in-process — ExoPlayer on a +# device — is driven by the same cases as the ones that can, rather than by a +# second checklist that drifts. +conformance = [] + +# A standalone runner for the conformance suite. Deliberately a separate binary: +# it links libmpv and nothing else, so a wrapper can be verified without building +# or launching the app. +[[bin]] +name = "player-conformance" +path = "src/bin/player_conformance.rs" +required-features = ["conformance"] + diff --git a/src-tauri/src/bin/player_conformance.rs b/src-tauri/src/bin/player_conformance.rs new file mode 100644 index 00000000..0f83eef9 --- /dev/null +++ b/src-tauri/src/bin/player_conformance.rs @@ -0,0 +1,19 @@ +//! Thin entry point. The suite lives in the library so the binary needs no +//! access to the player internals — one exported function rather than a public +//! module tree. +//! +//! TRACES: UR-081 | DR-244 + +use std::process::ExitCode; + +fn main() -> ExitCode { + let Some(url) = std::env::args().nth(1) else { + eprintln!("usage: player-conformance "); + return ExitCode::from(2); + }; + if jellytau_lib::conformance_runner::run(&url) == 0 { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} diff --git a/src-tauri/src/conformance_runner.rs b/src-tauri/src/conformance_runner.rs new file mode 100644 index 00000000..f3c7d055 --- /dev/null +++ b/src-tauri/src/conformance_runner.rs @@ -0,0 +1,117 @@ +//! Runs the `MediaPlayer` conformance suite against a real engine. +//! +//! A separate binary on purpose: it links libmpv and nothing else, so a wrapper +//! can be verified without building or launching the app — which is what made +//! the previous round of playback debugging so slow. Every failure here is a +//! wrapper bug, with no UI, no webview and no server in the way. +//! +//! cargo run --features conformance --bin player-conformance -- +//! +//! Audio and video are routed to null, so it is safe on a headless runner and +//! does not claim the speakers. +//! +//! TRACES: UR-081 | DR-244 + +use std::time::{Duration, Instant}; + +use crate::player::conformance::Harness; +use crate::player::media::MediaItem; +use crate::player::media_player::{MediaPlayer, OpenRequest, Phase}; +use crate::player::mpv_player::{MpvPlayer, Output}; +use crate::repository::stream_selection::StreamSelection; + +struct MpvHarness { + player: MpvPlayer, + url: String, +} + +impl Harness for MpvHarness { + type Player = MpvPlayer; + + fn player(&mut self) -> &mut MpvPlayer { + &mut self.player + } + + fn request(&self, start: Duration) -> OpenRequest { + let selection = StreamSelection::local_file(self.url.clone()); + let media = MediaItem::sample("conformance", &self.url); + OpenRequest::new(media, selection).starting_at(start) + } + + /// Wait for mpv to leave `Opening`. + /// + /// Polling a phase the engine publishes, not a fixed sleep: a suite whose + /// result depends on how fast the machine is will eventually be ignored. + fn settle(&mut self) { + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + if self.player.snapshot().phase != Phase::Opening { + // Let the deferred seek land and one position tick arrive. + std::thread::sleep(Duration::from_millis(300)); + return; + } + std::thread::sleep(Duration::from_millis(25)); + } + eprintln!(" ! settle timed out - engine stayed in Opening"); + } + + /// mpv is on a null audio device here, so silence cannot be observed. + /// Reporting `None` skips those assertions rather than passing them + /// vacuously — an assertion that cannot fail is worse than an absent one. + fn audible(&mut self) -> Option { + None + } + + /// Keyframe granularity: mpv lands on the nearest one, not on the request. + fn seek_tolerance(&self) -> Duration { + Duration::from_secs(10) + } +} + +macro_rules! run { + ($failed:ident, $url:expr, $case:path) => {{ + let name = stringify!($case).rsplit("::").next().unwrap(); + print!(" {name:.<52}"); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut h = MpvHarness { + player: MpvPlayer::new(Output::Null).expect("could not create mpv"), + url: $url.to_string(), + }; + $case(&mut h); + // Leave nothing playing behind for the next case. + let _ = h.player.close(); + })); + match result { + Ok(()) => println!(" ok"), + Err(_) => { + println!(" FAILED"); + $failed += 1; + } + } + }}; +} + +/// Run every conformance case against `MpvPlayer`. Returns the failure count. +pub fn run(url: &str) -> u32 { + println!("MediaPlayer conformance - MpvPlayer"); + println!("media: {url}\n"); + + let mut failed = 0u32; + use crate::player::conformance as c; + run!(failed, url, c::opens_from_the_beginning); + run!(failed, url, c::opens_at_a_start_position); + run!(failed, url, c::seek_while_opening_is_honoured); + run!(failed, url, c::seek_while_opening_overrides_start); + run!(failed, url, c::seeks_after_open); + run!(failed, url, c::pause_and_play_are_observable); + run!(failed, url, c::close_is_silent_and_idempotent); + run!(failed, url, c::close_during_open_never_plays); + run!(failed, url, c::transport_settings_round_trip); + + if failed == 0 { + println!("\nall cases passed"); + } else { + println!("\n{failed} case(s) failed"); + } + failed +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 528ec8d2..216e704c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,10 @@ mod android_context; mod auth; mod commands; +/// The MediaPlayer conformance suite, exposed for the `player-conformance` +/// binary. One entry point rather than a public player module tree. +#[cfg(feature = "conformance")] +pub mod conformance_runner; mod connectivity; mod credentials; mod domain; diff --git a/src-tauri/src/player/mpv_player.rs b/src-tauri/src/player/mpv_player.rs new file mode 100644 index 00000000..7fc00354 --- /dev/null +++ b/src-tauri/src/player/mpv_player.rs @@ -0,0 +1,378 @@ +//! [`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::{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() + .map(Duration::from_secs_f64); + 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, + } + } +}