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.
1232 lines
52 KiB
Rust
1232 lines
52 KiB
Rust
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:?}"));
|
||
}
|
||
}
|
||
}
|
||
}
|