Security (DR-298, DR-299): - The pinned libmpv crate's Mpv::command joins its arguments and calls mpv_command_string, which parses `;` as a command separator. Stream URLs carry server-controlled ids and TranscodingUrl, and a download's file:// path carries its track title, so a crafted title could run any mpv command, `run` included. Every call now goes through mpv_command::command, an argv built for mpv_command. The same parse broke loadfile for every downloaded title containing a space. - mpv's tls-verify defaults to no, and its URLs carry the ApiKey. Every handle is now hardened with tls-verify=yes and ytdl=no before its first loadfile, and fails construction if it cannot be. Linux video (DR-235 phase 1): - native_video::enabled() is unconditional on Linux; the JELLYTAU_NATIVE_VIDEO opt-in is retired. No platform reports a webview video fallback, so the Settings switch no longer appears. Windows keeps the webview element until mpv reaches it (DR-237). - The Linux device profile is unchanged (still h264, DR-234), so this ships the configuration that was tested under the env var.
386 lines
14 KiB
Rust
386 lines
14 KiB
Rust
//! [`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<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:?}"),
|
|
})?;
|
|
// TRACES: UR-012 | DR-299
|
|
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
|
|
|
|
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()
|
|
.and_then(duration_from_secs);
|
|
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);
|
|
// TRACES: UR-003, UR-004 | DR-298
|
|
super::mpv_command::command(&self.mpv, &["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) = super::mpv_command::command(&self.mpv, &["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,
|
|
// 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,
|
|
}
|
|
}
|
|
}
|