Files
jellytau/src-tauri/src/player/mpv_backend.rs
T
dtourolle 1677f5f299 refactor(player): delete the webview video path; mpv selects its own tracks
DR-235 phase 3. Every video renderer is native now: mpv on Linux and
Windows, ExoPlayer on Android, all drawing behind the transparent
webview. The HTML5 <video> path is gone, not bypassed:

- Frontend: hls.js, Html5PlayerAdapter and its compatibility shim, the
  createAdapter factory, streamTransport, hlsRecovery, timeTracking,
  videoFit, the <video>/<track> markup and every element handler in
  VideoPlayer (3277 -> 2144 lines), the experimentalNativeVideo store
  and its Settings toggle, webviewVideoFallback/supportsNativeVideo, and
  the setHtml5VideoState PiP bridge call. NativePlayerAdapter is the one
  video adapter; webview audio gets its own adapter kind.
- Rust: use_html5 dropped from player_seek_video,
  player_switch_audio_track and player_set_stream_quality with the
  Html5* strategies and ReloadStream responses; use_html5_element and
  VideoBackend dropped from PlayerStatus; player_play_item always loads
  the backend (set_current_item removed); Capabilities::webview removed;
  the WebKitGTK GStreamer/VAAPI setup (and its gst-inspect spawn) removed.
- Android: the HTML5 video state in PictureInPictureManager and
  ScreenWakeManager, and the bridge method feeding it.
- CSP: connect-src loses http:/https: and worker-src loses blob: -
  both existed for hls.js; with it gone they were only an exfiltration
  channel and a blob worker for injected script. A test now keeps them
  out.

mpv takes over what the <video> element did (mpv_tracks, UT-275):
subtitles are the WebVTT list the play request carries, queued on
sub-files and selected by position in that list, starting off; audio
tracks are selected by position in the file; sid/aid are reset before
each load. Without this, Linux video had no subtitle selection and a
direct-play audio switch failed since mpv became its renderer.

Verified: Rust 948 passing, and the same 948 cross-compiled for Windows
under wine against the shipped DLL (track tests included); frontend
1111 passing; aarch64 debug APK builds. Lint warnings 158 -> 146, CI
ratchet tightened to match. Not yet seen on Windows hardware.
2026-09-24 23:11:17 -04:00

1232 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
use super::media::{MediaItem, MediaSource};
use super::state::PlayerState;
use super::stream_end::ObservedTime;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
use crate::utils::lock::MutexSafe;
use libmpv::Mpv;
use log::{debug, error, info, warn};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::Mutex as TokioMutex;
/// MPV-based player backend for Linux
///
/// Uses libmpv for audio playback with full control over playback state,
/// position tracking, and event handling.
pub struct MpvBackend {
mpv: Arc<Mpv>,
state: Arc<Mutex<InternalState>>,
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
audio_settings: AudioSettings,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
last_seek_time: Arc<AtomicU64>,
/// Last position/duration seen while a file was loaded.
///
/// `time-pos` and `duration` are live properties of the *loaded* file: at
/// EOF MPV unloads it and both stop resolving, so reading them straight
/// through reported 0.0 / unknown exactly when end-of-file handling needed to
/// know where playback reached. See [`ObservedTime`].
observed: Arc<Mutex<ObservedTime>>,
/// A seek that arrived before MPV had a file to seek in.
///
/// `loadfile` is asynchronous: it returns as soon as the command is queued,
/// so `time-pos` is not yet a resolvable property and setting it fails. A
/// seek issued in that window used to be dropped on the floor, and the two
/// callers that do exactly this are the ones a viewer notices — resume, and
/// a transcoded seek, both of which re-open the stream and then ask for a
/// position. The stream reloaded and played from zero.
///
/// Held here and applied by the `FileLoaded` arm.
///
/// TRACES: UR-040, UR-005 | DR-241
pending_seek: Arc<Mutex<Option<f64>>>,
}
struct InternalState {
current_media: Option<MediaItem>,
volume: f32,
}
/// The audio output mpv should use on Windows: WASAPI, the only one it ships
/// there. Nothing to probe — and spawning `pactl` from a GUI app on Windows
/// would at best fail and at worst flash a console window.
///
/// TRACES: UR-004 | DR-237
#[cfg(target_os = "windows")]
fn detect_audio_system() -> String {
"wasapi".to_string()
}
/// Detect which audio system is available on the system
#[cfg(not(target_os = "windows"))]
fn detect_audio_system() -> String {
use std::process::Command;
info!("[MpvBackend] Detecting audio system...");
// Try PulseAudio/PipeWire first (most common on modern Linux)
if let Ok(output) = Command::new("pactl").arg("info").output() {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("PipeWire") {
info!("[MpvBackend] Detected PipeWire (with PulseAudio compatibility)");
return "pulse".to_string();
} else if stdout.contains("PulseAudio") {
info!("[MpvBackend] Detected PulseAudio");
return "pulse".to_string();
}
}
}
// Try detecting PipeWire directly
if let Ok(output) = Command::new("pw-cli").arg("info").arg("0").output() {
if output.status.success() {
info!("[MpvBackend] Detected PipeWire");
return "pulse".to_string(); // PipeWire works with pulse driver
}
}
// Check if ALSA is available
if std::path::Path::new("/proc/asound/cards").exists() {
info!("[MpvBackend] Falling back to ALSA");
return "alsa".to_string();
}
// Default fallback
warn!("[MpvBackend] Could not detect audio system, using 'auto'");
"auto".to_string()
}
/// Helper to get stream URL from MediaItem
fn get_stream_url(media: &MediaItem) -> String {
match &media.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
// A Windows path is not a URL (`file://C:\...` is malformed); mpv
// takes the native path as it is. Safe to pass verbatim because it goes
// to mpv as one argv element (DR-298), not through a command string.
// TRACES: UR-004, UR-071 | DR-237
MediaSource::Local { file_path, .. } if cfg!(target_os = "windows") => {
file_path.to_string_lossy().into_owned()
}
MediaSource::Local { file_path, .. } => {
format!("file://{}", file_path.to_string_lossy())
}
MediaSource::DirectUrl { url } => url.clone(),
}
}
/// The mpv handle of the backend this process created, for the video surface.
///
/// A `OnceLock` rather than a field reached through `PlayerBackend`, because the
/// trait is cross-platform and a raw mpv pointer is not something every backend
/// should have to pretend to have. Stored as `usize` because a raw pointer is
/// neither `Send` nor `Sync`; the only consumer is the GTK main thread, which is
/// also where mpv was created.
///
/// Written once at construction and never cleared: the backend outlives the
/// window, so there is no window in which this could dangle while a surface is
/// still using it.
///
/// TRACES: UR-080 | DR-231
static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
/// The registered handle, or null if no MPV backend was created (initialisation
/// can fail, and the app falls back to a no-op backend rather than dying).
///
/// TRACES: UR-080 | DR-231
// Only the Linux video surface reads it until Windows gets one (DR-237).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
MPV_HANDLE
.get()
.map(|p| *p as *mut libmpv_sys::mpv_handle)
.unwrap_or(std::ptr::null_mut())
}
/// How mpv shows video on this platform.
///
/// TRACES: UR-080 | DR-231, DR-237
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum VideoOutput {
/// No picture: audio-only playback, or nowhere to draw.
Off,
/// Linux: frames through the render API into the GTK surface beneath the
/// webview (`video_surface`).
RenderApi,
/// Windows: mpv renders as a child of the app's own window (`wid`, set
/// before initialisation), beneath the transparent WebView2 — the
/// arrangement tauri-plugin-libmpv ships on Windows.
Window(i64),
}
impl VideoOutput {
/// Runtime options for this output. `wid` is not among them: it only takes
/// effect before initialisation, so the constructor sets it separately.
pub(crate) fn options(&self) -> Vec<(&'static str, String)> {
match self {
VideoOutput::Off => vec![("video", "no".to_string())],
VideoOutput::RenderApi => vec![("vo", "libmpv".to_string())],
VideoOutput::Window(_) => [
// libplacebo's renderer, with the classic one as fallback for a
// build or GPU that lacks it.
("vo", "gpu-next,gpu"),
// mpv is a surface here, not a player: the app's controls are
// drawn over it, so its own controller and bindings must not
// answer clicks, keys or the cursor.
("osc", "no"),
("input-default-bindings", "no"),
("input-vo-keyboard", "no"),
("input-cursor", "no"),
("cursor-autohide", "no"),
]
.into_iter()
.map(|(k, v)| (k, v.to_string()))
.collect(),
}
}
}
/// Decide the video output from whether native video is on, the platform, and
/// the app window's handle (Windows only).
///
/// TRACES: UR-080 | DR-231, DR-237 | UT-274
pub(crate) fn video_output(native: bool, is_windows: bool, window: Option<i64>) -> VideoOutput {
match (native, is_windows, window) {
(false, _, _) => VideoOutput::Off,
(true, true, Some(wid)) => VideoOutput::Window(wid),
// No handle: mpv would open a top-level window of its own.
(true, true, None) => VideoOutput::Off,
(true, false, _) => VideoOutput::RenderApi,
}
}
impl MpvBackend {
/// Create a new MPV backend
///
/// `video_window` is the app window's native handle (an HWND), which mpv
/// draws video into on Windows; `None` elsewhere.
pub fn new(
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
video_window: Option<i64>,
) -> Result<Self, PlayerError> {
info!("[MpvBackend] Initializing MPV backend...");
// MPV requires LC_NUMERIC to be set to "C" locale
// Set it before initializing MPV, then restore it after
use std::ffi::CString;
unsafe {
let c_locale = CString::new("C").unwrap();
libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
}
let output = video_output(
super::native_video::enabled(),
cfg!(target_os = "windows"),
video_window,
);
if super::native_video::enabled() && output == VideoOutput::Off {
error!("[MpvBackend] no window handle to draw video into; video will have no picture");
}
// `wid` only takes effect before initialisation. TRACES: UR-080 | DR-237
let mpv = Mpv::with_initializer(|init| {
if let VideoOutput::Window(wid) = output {
init.set_property("wid", wid)?;
}
Ok(())
})
.map_err(|e| PlayerError {
message: format!("Failed to initialize MPV: {:?}", e),
})?;
// TRACES: UR-012 | DR-299
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
// Detect and configure audio output
let audio_driver = detect_audio_system();
info!(
"[MpvBackend] Configuring audio output driver: {}",
audio_driver
);
mpv.set_property("ao", audio_driver.as_str())
.map_err(|e| PlayerError {
message: format!(
"Failed to set audio output to '{}': {:?}. Make sure audio system is working.",
audio_driver, e
),
})?;
// Enable verbose logging for audio initialization
mpv.set_property("msg-level", "all=warn,ao=debug")
.unwrap_or_else(|e| {
warn!("[MpvBackend] Warning: Could not set MPV log level: {:?}", e);
});
// Configure MPV for audio playback
mpv.set_property("audio-display", "no")
.map_err(|e| PlayerError {
message: format!("Failed to configure MPV audio-display: {:?}", e),
})?;
// Video is disabled unless this process is drawing it.
//
// Linux video went through the webview until DR-235, and decoding it
// here too would have burned a core for a picture nobody saw — hence
// `video: no`. With native video, mpv needs the decoder *and* an output
// that draws where the app wants it: the render API on Linux (the default
// would open a window of its own), the app's window on Windows.
//
// Set at construction because mpv resolves the video output when it
// initialises; flipping it later does not re-open one.
//
// TRACES: UR-080 | DR-231, DR-235, DR-237
for (name, value) in output.options() {
mpv.set_property(name, value.as_str())
.map_err(|e| PlayerError {
message: format!("Failed to set {name}={value}: {:?}", e),
})?;
}
info!("[MpvBackend] video output: {:?}", output);
// Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64)
.map_err(|e| PlayerError {
message: format!("Failed to set initial volume: {:?}", e),
})?;
// Survive a flaky connection instead of dying on it. Without these,
// ffmpeg's HTTP demuxer gives up the moment a read fails and MPV raises
// EndFile(ERROR) — a blip on wifi kills the track outright. Reconnecting
// in the demuxer handles the common case entirely below our level, so
// most outages never reach the recovery in `player_recover_stream`.
//
// Non-fatal: these are ffmpeg-side options whose availability varies with
// the libmpv/ffmpeg build, and losing resilience is not a reason to
// refuse to play anything (graceful backend init, CLAUDE.md).
mpv.set_property(
"stream-lavf-o",
"reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
)
.unwrap_or_else(|e| {
warn!(
"[MpvBackend] Could not enable stream reconnection: {:?} — \
playback will not survive network interruptions",
e
);
});
mpv.set_property("network-timeout", 15i64)
.unwrap_or_else(|e| {
warn!("[MpvBackend] Could not set network timeout: {:?}", e);
});
let state = Arc::new(Mutex::new(InternalState {
current_media: None,
volume: 1.0,
}));
let backend = MpvBackend {
mpv: {
let mpv = Arc::new(mpv);
// Publish the handle for the video surface (DR-231). Ignores a
// second call: only one MPV backend is ever constructed, and a
// failed re-init must not replace a live handle.
let _ = MPV_HANDLE.set(mpv.ctx.as_ptr() as usize);
mpv
},
state,
event_emitter,
audio_settings: AudioSettings::default(),
playback_reporter,
position_throttler,
last_seek_time: Arc::new(AtomicU64::new(0)),
pending_seek: Arc::new(Mutex::new(None)),
observed: Arc::new(Mutex::new(ObservedTime::default())),
};
// Start event loop in background thread
backend.start_event_loop();
info!("[MpvBackend] Initialized successfully");
Ok(backend)
}
/// Start the MPV event loop in a background thread
fn start_event_loop(&self) {
let mpv = self.mpv.clone();
let event_emitter = self.event_emitter.clone();
let state = self.state.clone();
let reporter = self.playback_reporter.clone();
let throttler = self.position_throttler.clone();
let pending_seek_for_events = self.pending_seek.clone();
std::thread::spawn(move || {
info!("[MpvBackend] Event loop started");
let mut ev_ctx = mpv.create_event_context();
ev_ctx.disable_deprecated_events().unwrap_or_else(|e| {
error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
});
// libmpv delivers PropertyChange only for properties registered
// here. Every name matched in the loop below needs a line in this
// block or its handler is unreachable — an omission that reads as
// working code, because the handler is sitting right there.
// UT-218 holds the two lists together.
//
// `pause` drives the play/pause control: the UI consumes
// StateChanged rather than tracking playback itself, per the
// one-directional state rule. Unobserved, the event never came and
// the button never moved. Invisible until native video shipped,
// because the (since deleted) webview <video> element's own DOM
// events drove that control on Linux.
//
// TRACES: UR-005 | DR-239
ev_ctx
.observe_property("pause", libmpv::Format::Flag, 0)
.unwrap_or_else(|e| {
error!(
"[MpvBackend] Failed to observe 'pause': {:?} — the play/pause \
control will not follow the player",
e
);
});
loop {
match ev_ctx.wait_event(1.0) {
Some(Ok(event)) => match event {
libmpv::events::Event::StartFile => {
debug!("[MpvBackend] Starting file");
}
libmpv::events::Event::FileLoaded => {
info!("[MpvBackend] File loaded");
// Apply a seek that arrived while there was nothing
// to seek in. TRACES: UR-040, UR-005 | DR-241
{
let target = pending_seek_for_events.lock_safe().take();
if let Some(position) = target {
match mpv.set_property("time-pos", position) {
Ok(()) => info!(
"[MpvBackend] applied deferred seek to {position}"
),
Err(e) => warn!(
"[MpvBackend] deferred seek to {position} failed: {:?}",
e
),
}
}
}
// Geometry, so "the picture does not fill the screen"
// can be attributed rather than guessed at. `width`/
// `height` are the decoded frame; `dwidth`/`dheight`
// are what mpv will *display* after aspect
// correction. A file that carries its letterbox
// baked into the picture reports a 16:9 dwidth and
// is then pillarboxed on a wider panel — which looks
// identical to a rendering bug from outside.
{
let n = |k: &str| mpv.get_property::<i64>(k).unwrap_or(-1);
info!(
"[MpvBackend] video geometry: {}x{} decoded, {}x{} display, aspect {:?}",
n("width"),
n("height"),
n("dwidth"),
n("dheight"),
mpv.get_property::<f64>("video-params/aspect").ok(),
);
}
// Get duration
if let Ok(duration) = mpv.get_property::<f64>("duration") {
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
}
}
libmpv::events::Event::PlaybackRestart => {
debug!("[MpvBackend] Playback started/resumed");
let media_id = state
.lock_safe()
.current_media
.as_ref()
.map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
state: "playing".to_string(),
media_id,
});
}
}
libmpv::events::Event::PropertyChange { name: "pause", .. } => {
// Handle pause state changes
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
let media_id = state
.lock_safe()
.current_media
.as_ref()
.map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
state: if is_paused { "paused" } else { "playing" }
.to_string(),
media_id,
});
}
}
}
libmpv::events::Event::EndFile(reason) => {
debug!("[MpvBackend] End file with reason: {}", reason);
// Only emit PlaybackEnded for natural track completion (EOF = 0)
// Don't emit for Stop (2), Quit (3), Error (4), or other reasons
// Constants from MPV_END_FILE_REASON enum: EOF=0, STOP=2, QUIT=3, ERROR=4
const MPV_END_FILE_REASON_EOF: u32 = 0;
const MPV_END_FILE_REASON_STOP: u32 = 2;
const MPV_END_FILE_REASON_QUIT: u32 = 3;
const MPV_END_FILE_REASON_ERROR: u32 = 4;
if reason == MPV_END_FILE_REASON_EOF {
debug!("[MpvBackend] Track finished naturally (EOF), emitting PlaybackEnded");
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
} else if reason == MPV_END_FILE_REASON_STOP {
debug!("[MpvBackend] Track stopped (loading new track), NOT emitting PlaybackEnded");
// Don't emit - user is loading a new track
} else if reason == MPV_END_FILE_REASON_QUIT {
debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
// Don't emit - player is shutting down
} else if reason == MPV_END_FILE_REASON_ERROR {
// NOT PlaybackEnded — the track did not finish, so
// autoplay must not advance. It is an error, and it
// has to be *said*: emitting nothing here left
// playback halted with the UI still showing
// "playing" and no way back. Marked recoverable so
// the frontend echoes it into player_recover_stream,
// which re-opens the stream where it stopped —
// MPV's own reconnect handles shorter blips before
// they ever get this far.
warn!("[MpvBackend] Track ended with an error — reporting as recoverable");
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::Error {
message: "Playback stream failed".to_string(),
recoverable: true,
});
}
} else {
debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
}
}
libmpv::events::Event::Shutdown => {
info!("[MpvBackend] Shutdown event received");
break;
}
_ => {}
},
Some(Err(e)) => {
error!("[MpvBackend] Event error: {:?}", e);
}
None => {
// Timeout, continue
}
}
std::thread::sleep(Duration::from_millis(10));
}
info!("[MpvBackend] Event loop ended");
});
// Start position update thread
let mpv_for_position = self.mpv.clone();
let emitter_for_position = self.event_emitter.clone();
let state_for_position = self.state.clone();
let reporter_for_position = reporter.clone();
let throttler_for_position = throttler.clone();
let last_seek_time_for_position = self.last_seek_time.clone();
let observed_for_position = self.observed.clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_millis(250));
// Get current position and duration
// Note: We emit position updates even when paused so scrubbing works
if let (Ok(pos), Ok(dur)) = (
mpv_for_position.get_property::<f64>("time-pos"),
mpv_for_position.get_property::<f64>("duration"),
) {
// Remember it: both properties belong to the *loaded* file and
// stop resolving the instant MPV unloads it at EOF, which is
// exactly when end-of-file handling asks where playback got to.
// Recorded before the post-seek skip below so a track that ends
// right after a seek still reports the seek target, not zero.
observed_for_position.lock_safe().record(pos, dur);
// Check if we recently seeked - skip position updates briefly after seeks
// to avoid "jumping to zero" visual glitches while MPV is seeking
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let last_seek = last_seek_time_for_position.load(Ordering::Relaxed);
let time_since_seek = now.saturating_sub(last_seek);
// Skip position updates for 150ms after a seek to let MPV stabilize
if time_since_seek < 150 {
continue;
}
// Emit position update event (even when paused, for scrubbing)
if let Some(emitter) = &emitter_for_position {
emitter.emit(PlayerStatusEvent::PositionUpdate {
position: pos,
duration: dur,
});
}
// Check if we're playing for progress reporting
let is_paused = mpv_for_position
.get_property::<bool>("pause")
.unwrap_or(true);
// Only report progress to server when playing (not paused)
if !is_paused {
// Throttled progress reporting (every 30s)
let jellyfin_id = {
let state = state_for_position.lock_safe();
state
.current_media
.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
};
if let Some(item_id) = jellyfin_id {
if throttler_for_position.should_report(&item_id) {
let position_ticks = seconds_to_ticks(pos);
let reporter_clone = reporter_for_position.clone();
let item_id_clone = item_id.clone();
// Spawn async task to report progress
// Check if we're in a Tokio runtime, otherwise spawn a new thread with its own runtime
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let reporter_guard = reporter_clone.lock().await;
if let Some(reporter_instance) = reporter_guard.as_ref() {
let operation = PlaybackOperation::Progress {
item_id: item_id_clone.clone(),
position_ticks,
is_paused: false,
};
match reporter_instance.report(operation, true).await {
Ok(_) => debug!(
"[MpvBackend] Reported progress for {}",
item_id_clone
),
Err(e) => warn!(
"[MpvBackend] Failed to report progress: {}",
e
),
}
}
});
} else {
// Fallback: spawn in a new thread with its own runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
let reporter_guard = reporter_clone.lock().await;
if let Some(reporter_instance) = reporter_guard.as_ref() {
let operation = PlaybackOperation::Progress {
item_id: item_id_clone.clone(),
position_ticks,
is_paused: false,
};
match reporter_instance.report(operation, true).await {
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
}
}
});
});
}
throttler_for_position.mark_reported(&item_id);
}
}
}
}
}
});
}
}
impl PlayerBackend for MpvBackend {
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
let stream_url = get_stream_url(media);
info!("[MpvBackend] Loading: {} - {}", media.title, stream_url);
// Update state
{
let mut state = self.state.lock_safe();
state.current_media = Some(media.clone());
}
// A different file: the previous one's timestamp must not survive as this
// one's "last observed" position.
self.observed.lock_safe().reset();
// Nor its deferred seek. A seek held for a file that is no longer the
// one loading would be applied to this one by the `FileLoaded` handler
// — so scrubbing near the end of a transcoded item, which re-opens the
// stream, and then skipping to the next item before the reload finished
// started the new item wherever the old one had been scrubbed to.
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
// The item's own sideloaded subtitles, none shown, and its default audio
// track — whatever the previous item had chosen. Only video carries
// subtitles; for audio this just clears the last item's.
// TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
let subtitle_urls: Vec<&str> = media.subtitles.iter().map(|t| t.url.as_str()).collect();
super::mpv_tracks::prepare_load(&self.mpv, &subtitle_urls).map_err(|e| PlayerError {
message: format!("Failed to prepare tracks: {e}"),
})?;
// Load the media file. Through `mpv_command::command`, never
// `Mpv::command`: the URL carries server-controlled text.
// TRACES: UR-003, UR-004 | DR-298
super::mpv_command::command(&self.mpv, &["loadfile", &stream_url]).map_err(|e| {
PlayerError {
message: format!("Failed to load file: {e}"),
}
})?;
debug!("[MpvBackend] Load command sent successfully");
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Play command");
self.mpv
.set_property("pause", false)
.map_err(|e| PlayerError {
message: format!("Failed to play: {:?}", e),
})?;
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Pause command");
self.mpv
.set_property("pause", true)
.map_err(|e| PlayerError {
message: format!("Failed to pause: {:?}", e),
})?;
Ok(())
}
fn stop(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Stop command");
super::mpv_command::command(&self.mpv, &["stop"]).map_err(|e| PlayerError {
message: format!("Failed to stop: {e}"),
})?;
// Stopping ends the seek's subject along with the playback.
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
let mut state = self.state.lock_safe();
state.current_media = None;
Ok(())
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
debug!("[MpvBackend] Seek to {} seconds", position);
// Record the seek time to suppress position updates briefly
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
self.last_seek_time.store(now, Ordering::Relaxed);
// `time-pos` only resolves while a file is loaded. `loadfile` is
// asynchronous, so a seek issued straight after a reload — resume, or a
// transcoded seek — lands in a window where this fails, and dropping it
// there is what makes the stream play from zero instead of the position
// that was asked for. Hold it and let `FileLoaded` apply it.
// TRACES: UR-040, UR-005 | DR-241
if let Err(e) = self.mpv.set_property("time-pos", position) {
debug!(
"[MpvBackend] seek to {position} deferred until the file loads ({:?})",
e
);
*self.pending_seek.lock_safe() = Some(position);
self.observed.lock_safe().record_position(position);
return Ok(());
}
// A seek that lands clears any earlier deferred one: the newer intent wins.
*self.pending_seek.lock_safe() = None;
// The poll thread suppresses updates for 150ms after a seek, so without
// this a file ending inside that window would report the pre-seek time.
self.observed.lock_safe().record_position(position);
Ok(())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
debug!("[MpvBackend] Set volume to {}", clamped);
// MPV expects volume as percentage (0-100)
let mpv_volume = volume_to_percent(clamped as f64) as i64;
self.mpv
.set_property("volume", mpv_volume)
.map_err(|e| PlayerError {
message: format!("Failed to set volume: {:?}", e),
})?;
let mut state = self.state.lock_safe();
state.volume = clamped;
Ok(())
}
/// Current position — the live `time-pos`, or the last one observed while a
/// file was loaded.
///
/// The fallback is the point: `time-pos` is a property of the *loaded* file,
/// so at EOF it stops resolving and a bare `unwrap_or(0.0)` reported 0:00 at
/// exactly the moment end-of-file handling asks where playback reached.
///
/// TRACES: UR-005 | DR-130 | UT-121
fn position(&self) -> f64 {
let live = self.mpv.get_property::<f64>("time-pos").ok();
self.observed.lock_safe().position_or_last(live)
}
/// Total duration — live, or the last one observed. Unloaded at EOF for the
/// same reason as `position`.
///
/// TRACES: UR-005 | DR-130 | UT-121
fn duration(&self) -> Option<f64> {
let live = self.mpv.get_property::<f64>("duration").ok();
self.observed.lock_safe().duration_or_last(live)
}
fn state(&self) -> PlayerState {
let state = self.state.lock_safe();
if let Some(ref media) = state.current_media {
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
let position = self.position();
let duration = self.duration().unwrap_or(0.0);
if is_paused {
PlayerState::Paused {
media: media.clone(),
position,
duration,
}
} else {
PlayerState::Playing {
media: media.clone(),
position,
duration,
}
}
} else {
PlayerState::Idle
}
}
fn volume(&self) -> f32 {
let state = self.state.lock_safe();
state.volume
}
/// `stream_index` is a *position*: the n-th audio track of the file, the
/// same meaning ExoPlayer gives it (`player_switch_audio_track` passes the
/// array index). Only reached for a direct play/stream — a transcode carries
/// one track and is re-opened instead.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-235
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let position = usize::try_from(stream_index).map_err(|_| PlayerError {
message: format!("Invalid audio track position {stream_index}"),
})?;
super::mpv_tracks::select_audio(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
/// `stream_index` is the position in the sideloaded subtitle list the play
/// request carried (`nativeSubtitleArrayIndex`), `None` to hide subtitles.
///
/// TRACES: UR-020 | IR-018, DR-023, DR-235
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let position = stream_index
.map(|i| {
usize::try_from(i).map_err(|_| PlayerError {
message: format!("Invalid subtitle position {i}"),
})
})
.transpose()?;
super::mpv_tracks::select_subtitle(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
info!("[MpvBackend] Applying audio settings");
self.audio_settings = settings.clone();
// Apply gapless playback
if settings.gapless_playback {
self.mpv
.set_property("gapless-audio", "yes")
.map_err(|e| PlayerError {
message: format!("Failed to enable gapless: {:?}", e),
})?;
} else {
self.mpv
.set_property("gapless-audio", "no")
.map_err(|e| PlayerError {
message: format!("Failed to disable gapless: {:?}", e),
})?;
}
// Audio filter chain: build a single lavfi graph combining the EQ
// peaking bands and (optionally) a dynamic loudness normalizer, and
// set the `af` property. An empty string clears all filters. Both
// features share one `af` graph because MPV exposes a single filter
// property. See docs/architecture/05-platform-backends.md and IR-020.
let af = build_af_filter(settings);
self.mpv
.set_property("af", af.as_str())
.map_err(|e| PlayerError {
message: format!("Failed to set audio filters: {:?}", e),
})?;
// TODO: Implement crossfade via MPV audio filters if needed
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
self.audio_settings.clone()
}
}
/// Build the full MPV `af` (audio filter) value from the audio settings.
///
/// Combines the equalizer peaking bands and the loudness-normalization filter
/// into a single `lavfi` graph, because MPV exposes one `af` property. The
/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
/// empty string when neither feature contributes a filter, which clears `af`.
///
/// TRACES: UR-027, UR-033 | IR-020, DR-036
fn build_af_filter(settings: &AudioSettings) -> String {
let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
entries.push(norm);
}
if entries.is_empty() {
return String::new();
}
format!("lavfi=[{}]", entries.join(","))
}
/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
/// peaking) per band with a non-zero gain, e.g.
/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
/// is disabled or every gain is ~0. Gains are assumed already normalised by
/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
/// ignored.
///
/// TRACES: UR-027 | IR-020
fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
if !enabled {
return Vec::new();
}
bands
.iter()
.zip(EQ_BANDS.iter())
.filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
.map(|(gain, freq)| {
// width_type=o → octave bandwidth; width=1 → one octave per band.
format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
})
.collect()
}
/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
/// [`VolumeLevel::Normal`] (−14 LUFS) target, leaving −1.2 dB of headroom.
const NORMALIZE_REF_PEAK: f32 = 0.87;
/// Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).
const NORMALIZE_REF_LUFS: f32 = -14.0;
/// The loudness-normalization filter entry (unwrapped), or `None` when
/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
/// mode can produce on very dynamic material.
///
/// `dynaudnorm` targets a peak amplitude (`p`, linear 0–1), not a LUFS value,
/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
/// offset from the Normal reference is applied as a dB offset to the reference
/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
/// so loud presets never request full-scale.
///
/// TRACES: UR-033 | DR-036
fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
if !enabled {
return None;
}
// LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
// 3 decimals is plenty for a peak target and keeps the filter string stable.
Some(format!("dynaudnorm=p={:.3}:g=15", peak))
}
impl Drop for MpvBackend {
fn drop(&mut self) {
info!("[MpvBackend] Shutting down");
// MPV will be automatically cleaned up
}
}
#[cfg(test)]
mod af_filter_tests {
use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
use crate::settings::{AudioSettings, VolumeLevel};
fn settings() -> AudioSettings {
AudioSettings {
equalizer_enabled: false,
equalizer_bands: vec![0.0; 10],
normalize_volume: false,
..AudioSettings::default()
}
}
/// Disabled EQ, or an all-zero curve, produces no EQ entries.
///
/// TRACES: UR-027 | IR-020 | UT-083
#[test]
fn test_eq_entries_empty_when_disabled_or_flat() {
assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
// Sub-threshold gains count as flat.
assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
}
/// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
/// centre frequency and gain, chained inside a single `lavfi` filter.
///
/// TRACES: UR-027 | IR-020 | UT-084
#[test]
fn test_eq_filter_builds_lavfi_chain() {
// First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
let mut s = settings();
s.equalizer_enabled = true;
s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let af = build_af_filter(&s);
assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
assert!(af.ends_with("]"));
assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
// Only two bands are non-zero → exactly two peaking filters.
assert_eq!(af.matches("equalizer=").count(), 2);
}
/// Disabled normalization yields no filter entry; the combined `af` for a
/// fully default (all-off) settings is empty, which clears `af`.
///
/// TRACES: UR-033 | DR-036 | UT-085
#[test]
fn test_normalize_disabled_produces_no_filter() {
assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
assert_eq!(build_af_filter(&settings()), "");
}
/// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
/// the peak preserves the Loud > Normal > Quiet ordering.
///
/// TRACES: UR-033 | DR-036 | UT-086
#[test]
fn test_normalize_peak_preserves_preset_ordering() {
fn peak_of(entry: &str) -> f32 {
// "dynaudnorm=p=0.870:g=15" → 0.870
entry
.split("p=")
.nth(1)
.and_then(|s| s.split(':').next())
.and_then(|s| s.parse().ok())
.expect("parseable peak")
}
let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
for entry in [&loud, &normal, &quiet] {
assert!(
entry.starts_with("dynaudnorm="),
"dynaudnorm filter: {entry}"
);
}
assert!(
peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
"Loud {} > Normal {} > Quiet {}",
peak_of(&loud),
peak_of(&normal),
peak_of(&quiet),
);
// Every preset stays within the safe (0, 0.99] clamp.
for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
}
let mut s = settings();
s.normalize_volume = true;
s.volume_level = VolumeLevel::Quiet;
let af = build_af_filter(&s);
assert!(af.starts_with("lavfi=["));
assert!(af.contains("dynaudnorm=p="));
}
/// EQ and normalization coexist in one `lavfi` graph, with the normalizer
/// placed after the EQ bands so it levels the post-EQ signal.
///
/// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
#[test]
fn test_eq_and_normalize_combine_in_order() {
let mut s = settings();
s.equalizer_enabled = true;
s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
s.normalize_volume = true;
s.volume_level = VolumeLevel::Normal;
let af = build_af_filter(&s);
let eq_pos = af.find("equalizer=").expect("has EQ");
let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
}
}
#[cfg(test)]
mod video_output_tests {
use super::{video_output, VideoOutput};
/// Windows draws into the app's own window: mpv is handed its HWND before
/// initialising and renders as a child of it, beneath the transparent
/// WebView2.
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn windows_native_video_renders_into_the_app_window() {
assert_eq!(
video_output(true, true, Some(0x1234)),
VideoOutput::Window(0x1234)
);
}
/// Without a handle mpv would open a top-level window of its own, a second
/// window floating beside the app. No picture is the honest failure.
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn windows_without_a_window_handle_draws_nothing() {
assert_eq!(video_output(true, true, None), VideoOutput::Off);
}
/// Linux keeps the render API the GTK surface draws from.
///
/// TRACES: UR-080 | DR-231 | UT-274
#[test]
fn linux_native_video_uses_the_render_api() {
assert_eq!(video_output(true, false, None), VideoOutput::RenderApi);
}
/// TRACES: UR-080 | DR-231 | UT-274
#[test]
fn no_native_video_decodes_no_picture() {
assert_eq!(video_output(false, true, Some(1)), VideoOutput::Off);
assert_eq!(video_output(false, false, None), VideoOutput::Off);
}
/// In the app's window mpv must not act as a player of its own: its
/// on-screen controller and key/mouse bindings would compete with the
/// Svelte controls drawn over it.
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn a_window_output_hands_all_input_to_the_app() {
let opts = VideoOutput::Window(7).options();
for (k, v) in [
("vo", "gpu-next,gpu"),
("osc", "no"),
("input-default-bindings", "no"),
("input-vo-keyboard", "no"),
("input-cursor", "no"),
("cursor-autohide", "no"),
] {
assert!(
opts.iter().any(|(ok, ov)| *ok == k && ov == v),
"missing {k}={v} in {opts:?}"
);
}
assert_eq!(
VideoOutput::Off.options(),
vec![("video", "no".to_string())]
);
}
/// Every option the outputs set is one this libmpv accepts, `wid` included —
/// against the real library, so a misspelt or removed option fails here
/// rather than as a player that will not start on a user's machine. Runs on
/// the Windows DLL too (under wine in the cross-build).
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn libmpv_accepts_every_video_output_option() {
let mpv = libmpv::Mpv::with_initializer(|init| {
init.set_property("wid", 0i64)?;
Ok(())
})
.expect("libmpv must accept wid before initialisation");
for output in [
VideoOutput::Off,
VideoOutput::RenderApi,
VideoOutput::Window(0),
] {
for (name, value) in output.options() {
mpv.set_property(name, value.as_str())
.unwrap_or_else(|e| panic!("libmpv rejected {name}={value}: {e:?}"));
}
}
}
}