A recoverable player error meant "playback is over": the frontend's error handler stopped the player unconditionally, so a wifi blip killed the track. Android already decides in its JNI callback, but MpvBackend is constructed before PlayerController exists, so its event thread has no controller to ask. So MPV reports the failure and the frontend echoes it into the new player_recover_stream command — the same shape as PlaybackEnded -> player_on_playback_ended, keeping the decision in Rust. The command re-opens the stream where it stopped, with the existing attempt budget and backoff, and returns whether it handled it; only a false answer falls through to the old stop path. Android now reports the errors it has already declined as *unrecoverable*, so the echo never asks the same question twice. TRACES: UR-004, UR-040 | DR-130 | UT-117
848 lines
35 KiB
Rust
848 lines
35 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::process::Command;
|
||
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>>,
|
||
}
|
||
|
||
struct InternalState {
|
||
current_media: Option<MediaItem>,
|
||
volume: f32,
|
||
}
|
||
|
||
/// Detect which audio system is available on the system
|
||
fn detect_audio_system() -> String {
|
||
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(),
|
||
MediaSource::Local { file_path, .. } => {
|
||
format!("file://{}", file_path.to_string_lossy())
|
||
}
|
||
MediaSource::DirectUrl { url } => url.clone(),
|
||
}
|
||
}
|
||
|
||
impl MpvBackend {
|
||
/// Create a new MPV backend
|
||
pub fn new(
|
||
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
|
||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||
position_throttler: Arc<EventThrottler>,
|
||
) -> 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 mpv = Mpv::new().map_err(|e| PlayerError {
|
||
message: format!("Failed to initialize MPV: {:?}", e),
|
||
})?;
|
||
|
||
// 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),
|
||
})?;
|
||
|
||
mpv.set_property("video", "no").map_err(|e| PlayerError {
|
||
message: format!("Failed to configure MPV video: {:?}", e),
|
||
})?;
|
||
|
||
// 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: Arc::new(mpv),
|
||
state,
|
||
event_emitter,
|
||
audio_settings: AudioSettings::default(),
|
||
playback_reporter,
|
||
position_throttler,
|
||
last_seek_time: Arc::new(AtomicU64::new(0)),
|
||
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();
|
||
|
||
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);
|
||
});
|
||
|
||
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");
|
||
|
||
// 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, .. } if 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();
|
||
|
||
// Load the media file
|
||
self.mpv
|
||
.command("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");
|
||
|
||
self.mpv.command("stop", &[]).map_err(|e| PlayerError {
|
||
message: format!("Failed to stop: {:?}", e),
|
||
})?;
|
||
|
||
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);
|
||
|
||
self.mpv
|
||
.set_property("time-pos", position)
|
||
.map_err(|e| PlayerError {
|
||
message: format!("Failed to seek: {:?}", e),
|
||
})?;
|
||
|
||
// 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
|
||
}
|
||
|
||
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/specs/audio-equalizer.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}");
|
||
}
|
||
}
|