feat(player): MpvPlayer, and a runner that verifies it without the app

DR-244. The first real engine on the contract, and the tooling to interrogate
it in isolation.

The point of difference from MpvBackend is `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 with nothing loaded, fails, and was discarded. A seek that does arrive
during Opening is held and applied on FileLoaded, so no caller has to know
where that window begins or ends.

`close` clears state before issuing the stop, so an open still in flight
checks it on FileLoaded and cannot proceed to play after the caller has
stopped it. It is idempotent: callers legitimately close twice on teardown.

Every property the event loop matches is observed, per DR-239.

The runner is a separate binary that 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. Audio and video go to
null, so it is safe on a headless runner and does not claim the speakers. It
lives behind a `conformance` feature and exposes one entry point rather than
making the player module tree public.

    cargo run --features conformance --bin player-conformance -- <media-file>

All nine cases pass against real libmpv. Verified the suite can fail: reverting
`open` to the old load-then-seek behaviour makes opens_at_a_start_position fail
and restoring it makes it pass, so DR-241 is now a test rather than an
anecdote.
This commit is contained in:
2026-08-22 21:18:10 +02:00
parent f4892f4cb2
commit a3190cd52b
5 changed files with 533 additions and 0 deletions
+378
View File
@@ -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<Duration>,
seekable: bool,
/// A seek that arrived while opening. Applied on `FileLoaded`.
deferred_seek: Option<Duration>,
/// 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<Mpv>,
shared: Arc<Mutex<Shared>>,
volume: f32,
muted: bool,
rate: f64,
audio_track: Option<i32>,
subtitle_track: Option<i32>,
}
/// 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<Self, PlayerError> {
// 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::<f64>("duration")
.ok()
.map(Duration::from_secs_f64);
s.seekable = mpv.get_property::<bool>("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::<bool>("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::<f64>("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<i32>) -> 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<i32>) -> 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,
}
}
}