jellytau_lib/player/mpv_backend.rs
1use super::backend::{PlayerBackend, PlayerError};
2use super::events::{PlayerEventEmitter, PlayerStatusEvent};
3use super::media::{MediaItem, MediaSource};
4use super::state::PlayerState;
5use super::stream_end::ObservedTime;
6use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
7use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
8use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
9use crate::utils::lock::MutexSafe;
10use libmpv::Mpv;
11use log::{debug, error, info, warn};
12use std::process::Command;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16use tokio::sync::Mutex as TokioMutex;
17
18/// MPV-based player backend for Linux
19///
20/// Uses libmpv for audio playback with full control over playback state,
21/// position tracking, and event handling.
22pub struct MpvBackend {
23 mpv: Arc<Mpv>,
24 state: Arc<Mutex<InternalState>>,
25 event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
26 audio_settings: AudioSettings,
27 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
28 position_throttler: Arc<EventThrottler>,
29 last_seek_time: Arc<AtomicU64>,
30 /// Last position/duration seen while a file was loaded.
31 ///
32 /// `time-pos` and `duration` are live properties of the *loaded* file: at
33 /// EOF MPV unloads it and both stop resolving, so reading them straight
34 /// through reported 0.0 / unknown exactly when end-of-file handling needed to
35 /// know where playback reached. See [`ObservedTime`].
36 observed: Arc<Mutex<ObservedTime>>,
37 /// A seek that arrived before MPV had a file to seek in.
38 ///
39 /// `loadfile` is asynchronous: it returns as soon as the command is queued,
40 /// so `time-pos` is not yet a resolvable property and setting it fails. A
41 /// seek issued in that window used to be dropped on the floor, and the two
42 /// callers that do exactly this are the ones a viewer notices — resume, and
43 /// a transcoded seek, both of which re-open the stream and then ask for a
44 /// position. The stream reloaded and played from zero.
45 ///
46 /// Held here and applied by the `FileLoaded` arm.
47 ///
48 /// TRACES: UR-040, UR-005 | DR-241
49 pending_seek: Arc<Mutex<Option<f64>>>,
50}
51
52struct InternalState {
53 current_media: Option<MediaItem>,
54 volume: f32,
55}
56
57/// Detect which audio system is available on the system
58fn detect_audio_system() -> String {
59 info!("[MpvBackend] Detecting audio system...");
60
61 // Try PulseAudio/PipeWire first (most common on modern Linux)
62 if let Ok(output) = Command::new("pactl").arg("info").output() {
63 if output.status.success() {
64 let stdout = String::from_utf8_lossy(&output.stdout);
65 if stdout.contains("PipeWire") {
66 info!("[MpvBackend] Detected PipeWire (with PulseAudio compatibility)");
67 return "pulse".to_string();
68 } else if stdout.contains("PulseAudio") {
69 info!("[MpvBackend] Detected PulseAudio");
70 return "pulse".to_string();
71 }
72 }
73 }
74
75 // Try detecting PipeWire directly
76 if let Ok(output) = Command::new("pw-cli").arg("info").arg("0").output() {
77 if output.status.success() {
78 info!("[MpvBackend] Detected PipeWire");
79 return "pulse".to_string(); // PipeWire works with pulse driver
80 }
81 }
82
83 // Check if ALSA is available
84 if std::path::Path::new("/proc/asound/cards").exists() {
85 info!("[MpvBackend] Falling back to ALSA");
86 return "alsa".to_string();
87 }
88
89 // Default fallback
90 warn!("[MpvBackend] Could not detect audio system, using 'auto'");
91 "auto".to_string()
92}
93
94/// Helper to get stream URL from MediaItem
95fn get_stream_url(media: &MediaItem) -> String {
96 match &media.source {
97 MediaSource::Remote { stream_url, .. } => stream_url.clone(),
98 MediaSource::Local { file_path, .. } => {
99 format!("file://{}", file_path.to_string_lossy())
100 }
101 MediaSource::DirectUrl { url } => url.clone(),
102 }
103}
104
105/// The mpv handle of the backend this process created, for the video surface.
106///
107/// A `OnceLock` rather than a field reached through `PlayerBackend`, because the
108/// trait is cross-platform and a raw mpv pointer is not something every backend
109/// should have to pretend to have. Stored as `usize` because a raw pointer is
110/// neither `Send` nor `Sync`; the only consumer is the GTK main thread, which is
111/// also where mpv was created.
112///
113/// Written once at construction and never cleared: the backend outlives the
114/// window, so there is no window in which this could dangle while a surface is
115/// still using it.
116///
117/// TRACES: UR-080 | DR-231
118static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
119
120/// The registered handle, or null if no MPV backend was created (initialisation
121/// can fail, and the app falls back to a no-op backend rather than dying).
122///
123/// TRACES: UR-080 | DR-231
124pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
125 MPV_HANDLE
126 .get()
127 .map(|p| *p as *mut libmpv_sys::mpv_handle)
128 .unwrap_or(std::ptr::null_mut())
129}
130
131impl MpvBackend {
132 /// Create a new MPV backend
133 pub fn new(
134 event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
135 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
136 position_throttler: Arc<EventThrottler>,
137 ) -> Result<Self, PlayerError> {
138 info!("[MpvBackend] Initializing MPV backend...");
139
140 // MPV requires LC_NUMERIC to be set to "C" locale
141 // Set it before initializing MPV, then restore it after
142 use std::ffi::CString;
143 unsafe {
144 let c_locale = CString::new("C").unwrap();
145 libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
146 }
147
148 let mpv = Mpv::new().map_err(|e| PlayerError {
149 message: format!("Failed to initialize MPV: {:?}", e),
150 })?;
151
152 // Detect and configure audio output
153 let audio_driver = detect_audio_system();
154 info!(
155 "[MpvBackend] Configuring audio output driver: {}",
156 audio_driver
157 );
158
159 mpv.set_property("ao", audio_driver.as_str())
160 .map_err(|e| PlayerError {
161 message: format!(
162 "Failed to set audio output to '{}': {:?}. Make sure audio system is working.",
163 audio_driver, e
164 ),
165 })?;
166
167 // Enable verbose logging for audio initialization
168 mpv.set_property("msg-level", "all=warn,ao=debug")
169 .unwrap_or_else(|e| {
170 warn!("[MpvBackend] Warning: Could not set MPV log level: {:?}", e);
171 });
172
173 // Configure MPV for audio playback
174 mpv.set_property("audio-display", "no")
175 .map_err(|e| PlayerError {
176 message: format!("Failed to configure MPV audio-display: {:?}", e),
177 })?;
178
179 // Video is disabled unless this process is drawing it.
180 //
181 // `video: no` is why mpv has never decoded a frame here: Linux video has
182 // always gone through the webview, and decoding it twice would burn a
183 // core for a picture nobody sees. With native video on, mpv needs both
184 // the decoder *and* `vo=libmpv` — the render API only works through that
185 // output, and the default would try to open a window of its own.
186 //
187 // Set at construction because mpv resolves the video output when it
188 // initialises; flipping it later does not re-open one.
189 //
190 // TRACES: UR-080 | DR-231, DR-235
191 if super::native_video::enabled() {
192 mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
193 message: format!("Failed to select the libmpv video output: {:?}", e),
194 })?;
195 info!("[MpvBackend] native video enabled (vo=libmpv)");
196 } else {
197 mpv.set_property("video", "no").map_err(|e| PlayerError {
198 message: format!("Failed to configure MPV video: {:?}", e),
199 })?;
200 }
201
202 // Set volume to 100% (we'll control via MPV's volume property)
203 mpv.set_property("volume", 100i64)
204 .map_err(|e| PlayerError {
205 message: format!("Failed to set initial volume: {:?}", e),
206 })?;
207
208 // Survive a flaky connection instead of dying on it. Without these,
209 // ffmpeg's HTTP demuxer gives up the moment a read fails and MPV raises
210 // EndFile(ERROR) — a blip on wifi kills the track outright. Reconnecting
211 // in the demuxer handles the common case entirely below our level, so
212 // most outages never reach the recovery in `player_recover_stream`.
213 //
214 // Non-fatal: these are ffmpeg-side options whose availability varies with
215 // the libmpv/ffmpeg build, and losing resilience is not a reason to
216 // refuse to play anything (graceful backend init, CLAUDE.md).
217 mpv.set_property(
218 "stream-lavf-o",
219 "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
220 )
221 .unwrap_or_else(|e| {
222 warn!(
223 "[MpvBackend] Could not enable stream reconnection: {:?} — \
224 playback will not survive network interruptions",
225 e
226 );
227 });
228 mpv.set_property("network-timeout", 15i64)
229 .unwrap_or_else(|e| {
230 warn!("[MpvBackend] Could not set network timeout: {:?}", e);
231 });
232
233 let state = Arc::new(Mutex::new(InternalState {
234 current_media: None,
235 volume: 1.0,
236 }));
237
238 let backend = MpvBackend {
239 mpv: {
240 let mpv = Arc::new(mpv);
241 // Publish the handle for the video surface (DR-231). Ignores a
242 // second call: only one MPV backend is ever constructed, and a
243 // failed re-init must not replace a live handle.
244 let _ = MPV_HANDLE.set(mpv.ctx.as_ptr() as usize);
245 mpv
246 },
247 state,
248 event_emitter,
249 audio_settings: AudioSettings::default(),
250 playback_reporter,
251 position_throttler,
252 last_seek_time: Arc::new(AtomicU64::new(0)),
253 pending_seek: Arc::new(Mutex::new(None)),
254 observed: Arc::new(Mutex::new(ObservedTime::default())),
255 };
256
257 // Start event loop in background thread
258 backend.start_event_loop();
259
260 info!("[MpvBackend] Initialized successfully");
261 Ok(backend)
262 }
263
264 /// Start the MPV event loop in a background thread
265 fn start_event_loop(&self) {
266 let mpv = self.mpv.clone();
267 let event_emitter = self.event_emitter.clone();
268 let state = self.state.clone();
269 let reporter = self.playback_reporter.clone();
270 let throttler = self.position_throttler.clone();
271 let pending_seek_for_events = self.pending_seek.clone();
272
273 std::thread::spawn(move || {
274 info!("[MpvBackend] Event loop started");
275
276 let mut ev_ctx = mpv.create_event_context();
277 ev_ctx.disable_deprecated_events().unwrap_or_else(|e| {
278 error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
279 });
280
281 // libmpv delivers PropertyChange only for properties registered
282 // here. Every name matched in the loop below needs a line in this
283 // block or its handler is unreachable — an omission that reads as
284 // working code, because the handler is sitting right there.
285 // UT-218 holds the two lists together.
286 //
287 // `pause` drives the play/pause control: the UI consumes
288 // StateChanged rather than tracking playback itself, per the
289 // one-directional state rule. Unobserved, the event never came and
290 // the button never moved. Invisible until native video shipped,
291 // because the webview <video> element's own DOM events drove that
292 // control on Linux.
293 //
294 // TRACES: UR-005 | DR-239
295 ev_ctx
296 .observe_property("pause", libmpv::Format::Flag, 0)
297 .unwrap_or_else(|e| {
298 error!(
299 "[MpvBackend] Failed to observe 'pause': {:?} — the play/pause \
300 control will not follow the player",
301 e
302 );
303 });
304
305 loop {
306 match ev_ctx.wait_event(1.0) {
307 Some(Ok(event)) => match event {
308 libmpv::events::Event::StartFile => {
309 debug!("[MpvBackend] Starting file");
310 }
311 libmpv::events::Event::FileLoaded => {
312 info!("[MpvBackend] File loaded");
313
314 // Apply a seek that arrived while there was nothing
315 // to seek in. TRACES: UR-040, UR-005 | DR-241
316 {
317 let target = pending_seek_for_events.lock_safe().take();
318 if let Some(position) = target {
319 match mpv.set_property("time-pos", position) {
320 Ok(()) => info!(
321 "[MpvBackend] applied deferred seek to {position}"
322 ),
323 Err(e) => warn!(
324 "[MpvBackend] deferred seek to {position} failed: {:?}",
325 e
326 ),
327 }
328 }
329 }
330
331 // Geometry, so "the picture does not fill the screen"
332 // can be attributed rather than guessed at. `width`/
333 // `height` are the decoded frame; `dwidth`/`dheight`
334 // are what mpv will *display* after aspect
335 // correction. A file that carries its letterbox
336 // baked into the picture reports a 16:9 dwidth and
337 // is then pillarboxed on a wider panel — which looks
338 // identical to a rendering bug from outside.
339 {
340 let n = |k: &str| mpv.get_property::<i64>(k).unwrap_or(-1);
341 info!(
342 "[MpvBackend] video geometry: {}x{} decoded, {}x{} display, aspect {:?}",
343 n("width"),
344 n("height"),
345 n("dwidth"),
346 n("dheight"),
347 mpv.get_property::<f64>("video-params/aspect").ok(),
348 );
349 }
350
351 // Get duration
352 if let Ok(duration) = mpv.get_property::<f64>("duration") {
353 if let Some(emitter) = &event_emitter {
354 emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
355 }
356 }
357 }
358 libmpv::events::Event::PlaybackRestart => {
359 debug!("[MpvBackend] Playback started/resumed");
360
361 let media_id = state
362 .lock_safe()
363 .current_media
364 .as_ref()
365 .map(|m| m.id.clone());
366
367 if let Some(emitter) = &event_emitter {
368 emitter.emit(PlayerStatusEvent::StateChanged {
369 state: "playing".to_string(),
370 media_id,
371 });
372 }
373 }
374 libmpv::events::Event::PropertyChange { name: "pause", .. } => {
375 // Handle pause state changes
376 if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
377 let media_id = state
378 .lock_safe()
379 .current_media
380 .as_ref()
381 .map(|m| m.id.clone());
382
383 if let Some(emitter) = &event_emitter {
384 emitter.emit(PlayerStatusEvent::StateChanged {
385 state: if is_paused { "paused" } else { "playing" }
386 .to_string(),
387 media_id,
388 });
389 }
390 }
391 }
392 libmpv::events::Event::EndFile(reason) => {
393 debug!("[MpvBackend] End file with reason: {}", reason);
394
395 // Only emit PlaybackEnded for natural track completion (EOF = 0)
396 // Don't emit for Stop (2), Quit (3), Error (4), or other reasons
397 // Constants from MPV_END_FILE_REASON enum: EOF=0, STOP=2, QUIT=3, ERROR=4
398 const MPV_END_FILE_REASON_EOF: u32 = 0;
399 const MPV_END_FILE_REASON_STOP: u32 = 2;
400 const MPV_END_FILE_REASON_QUIT: u32 = 3;
401 const MPV_END_FILE_REASON_ERROR: u32 = 4;
402
403 if reason == MPV_END_FILE_REASON_EOF {
404 debug!("[MpvBackend] Track finished naturally (EOF), emitting PlaybackEnded");
405 if let Some(emitter) = &event_emitter {
406 emitter.emit(PlayerStatusEvent::PlaybackEnded);
407 }
408 } else if reason == MPV_END_FILE_REASON_STOP {
409 debug!("[MpvBackend] Track stopped (loading new track), NOT emitting PlaybackEnded");
410 // Don't emit - user is loading a new track
411 } else if reason == MPV_END_FILE_REASON_QUIT {
412 debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
413 // Don't emit - player is shutting down
414 } else if reason == MPV_END_FILE_REASON_ERROR {
415 // NOT PlaybackEnded — the track did not finish, so
416 // autoplay must not advance. It is an error, and it
417 // has to be *said*: emitting nothing here left
418 // playback halted with the UI still showing
419 // "playing" and no way back. Marked recoverable so
420 // the frontend echoes it into player_recover_stream,
421 // which re-opens the stream where it stopped —
422 // MPV's own reconnect handles shorter blips before
423 // they ever get this far.
424 warn!("[MpvBackend] Track ended with an error — reporting as recoverable");
425 if let Some(emitter) = &event_emitter {
426 emitter.emit(PlayerStatusEvent::Error {
427 message: "Playback stream failed".to_string(),
428 recoverable: true,
429 });
430 }
431 } else {
432 debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
433 }
434 }
435 libmpv::events::Event::Shutdown => {
436 info!("[MpvBackend] Shutdown event received");
437 break;
438 }
439 _ => {}
440 },
441 Some(Err(e)) => {
442 error!("[MpvBackend] Event error: {:?}", e);
443 }
444 None => {
445 // Timeout, continue
446 }
447 }
448
449 std::thread::sleep(Duration::from_millis(10));
450 }
451
452 info!("[MpvBackend] Event loop ended");
453 });
454
455 // Start position update thread
456 let mpv_for_position = self.mpv.clone();
457 let emitter_for_position = self.event_emitter.clone();
458 let state_for_position = self.state.clone();
459 let reporter_for_position = reporter.clone();
460 let throttler_for_position = throttler.clone();
461 let last_seek_time_for_position = self.last_seek_time.clone();
462 let observed_for_position = self.observed.clone();
463
464 std::thread::spawn(move || {
465 loop {
466 std::thread::sleep(Duration::from_millis(250));
467
468 // Get current position and duration
469 // Note: We emit position updates even when paused so scrubbing works
470 if let (Ok(pos), Ok(dur)) = (
471 mpv_for_position.get_property::<f64>("time-pos"),
472 mpv_for_position.get_property::<f64>("duration"),
473 ) {
474 // Remember it: both properties belong to the *loaded* file and
475 // stop resolving the instant MPV unloads it at EOF, which is
476 // exactly when end-of-file handling asks where playback got to.
477 // Recorded before the post-seek skip below so a track that ends
478 // right after a seek still reports the seek target, not zero.
479 observed_for_position.lock_safe().record(pos, dur);
480
481 // Check if we recently seeked - skip position updates briefly after seeks
482 // to avoid "jumping to zero" visual glitches while MPV is seeking
483 let now = SystemTime::now()
484 .duration_since(UNIX_EPOCH)
485 .unwrap()
486 .as_millis() as u64;
487 let last_seek = last_seek_time_for_position.load(Ordering::Relaxed);
488 let time_since_seek = now.saturating_sub(last_seek);
489
490 // Skip position updates for 150ms after a seek to let MPV stabilize
491 if time_since_seek < 150 {
492 continue;
493 }
494
495 // Emit position update event (even when paused, for scrubbing)
496 if let Some(emitter) = &emitter_for_position {
497 emitter.emit(PlayerStatusEvent::PositionUpdate {
498 position: pos,
499 duration: dur,
500 });
501 }
502
503 // Check if we're playing for progress reporting
504 let is_paused = mpv_for_position
505 .get_property::<bool>("pause")
506 .unwrap_or(true);
507
508 // Only report progress to server when playing (not paused)
509 if !is_paused {
510 // Throttled progress reporting (every 30s)
511 let jellyfin_id = {
512 let state = state_for_position.lock_safe();
513 state
514 .current_media
515 .as_ref()
516 .and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
517 };
518
519 if let Some(item_id) = jellyfin_id {
520 if throttler_for_position.should_report(&item_id) {
521 let position_ticks = seconds_to_ticks(pos);
522 let reporter_clone = reporter_for_position.clone();
523 let item_id_clone = item_id.clone();
524
525 // Spawn async task to report progress
526 // Check if we're in a Tokio runtime, otherwise spawn a new thread with its own runtime
527 if let Ok(handle) = tokio::runtime::Handle::try_current() {
528 handle.spawn(async move {
529 let reporter_guard = reporter_clone.lock().await;
530 if let Some(reporter_instance) = reporter_guard.as_ref() {
531 let operation = PlaybackOperation::Progress {
532 item_id: item_id_clone.clone(),
533 position_ticks,
534 is_paused: false,
535 };
536
537 match reporter_instance.report(operation, true).await {
538 Ok(_) => debug!(
539 "[MpvBackend] Reported progress for {}",
540 item_id_clone
541 ),
542 Err(e) => warn!(
543 "[MpvBackend] Failed to report progress: {}",
544 e
545 ),
546 }
547 }
548 });
549 } else {
550 // Fallback: spawn in a new thread with its own runtime
551 std::thread::spawn(move || {
552 let rt = tokio::runtime::Runtime::new().unwrap();
553 rt.block_on(async move {
554 let reporter_guard = reporter_clone.lock().await;
555 if let Some(reporter_instance) = reporter_guard.as_ref() {
556 let operation = PlaybackOperation::Progress {
557 item_id: item_id_clone.clone(),
558 position_ticks,
559 is_paused: false,
560 };
561
562 match reporter_instance.report(operation, true).await {
563 Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
564 Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
565 }
566 }
567 });
568 });
569 }
570
571 throttler_for_position.mark_reported(&item_id);
572 }
573 }
574 }
575 }
576 }
577 });
578 }
579}
580
581impl PlayerBackend for MpvBackend {
582 fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
583 let stream_url = get_stream_url(media);
584 info!("[MpvBackend] Loading: {} - {}", media.title, stream_url);
585
586 // Update state
587 {
588 let mut state = self.state.lock_safe();
589 state.current_media = Some(media.clone());
590 }
591 // A different file: the previous one's timestamp must not survive as this
592 // one's "last observed" position.
593 self.observed.lock_safe().reset();
594
595 // Load the media file
596 self.mpv
597 .command("loadfile", &[&stream_url])
598 .map_err(|e| PlayerError {
599 message: format!("Failed to load file: {:?}", e),
600 })?;
601
602 debug!("[MpvBackend] Load command sent successfully");
603 Ok(())
604 }
605
606 fn play(&mut self) -> Result<(), PlayerError> {
607 debug!("[MpvBackend] Play command");
608
609 self.mpv
610 .set_property("pause", false)
611 .map_err(|e| PlayerError {
612 message: format!("Failed to play: {:?}", e),
613 })?;
614
615 Ok(())
616 }
617
618 fn pause(&mut self) -> Result<(), PlayerError> {
619 debug!("[MpvBackend] Pause command");
620
621 self.mpv
622 .set_property("pause", true)
623 .map_err(|e| PlayerError {
624 message: format!("Failed to pause: {:?}", e),
625 })?;
626
627 Ok(())
628 }
629
630 fn stop(&mut self) -> Result<(), PlayerError> {
631 debug!("[MpvBackend] Stop command");
632
633 self.mpv.command("stop", &[]).map_err(|e| PlayerError {
634 message: format!("Failed to stop: {:?}", e),
635 })?;
636
637 let mut state = self.state.lock_safe();
638 state.current_media = None;
639
640 Ok(())
641 }
642
643 fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
644 debug!("[MpvBackend] Seek to {} seconds", position);
645
646 // Record the seek time to suppress position updates briefly
647 let now = SystemTime::now()
648 .duration_since(UNIX_EPOCH)
649 .unwrap()
650 .as_millis() as u64;
651 self.last_seek_time.store(now, Ordering::Relaxed);
652
653 // `time-pos` only resolves while a file is loaded. `loadfile` is
654 // asynchronous, so a seek issued straight after a reload — resume, or a
655 // transcoded seek — lands in a window where this fails, and dropping it
656 // there is what makes the stream play from zero instead of the position
657 // that was asked for. Hold it and let `FileLoaded` apply it.
658 // TRACES: UR-040, UR-005 | DR-241
659 if let Err(e) = self.mpv.set_property("time-pos", position) {
660 debug!(
661 "[MpvBackend] seek to {position} deferred until the file loads ({:?})",
662 e
663 );
664 *self.pending_seek.lock_safe() = Some(position);
665 self.observed.lock_safe().record_position(position);
666 return Ok(());
667 }
668
669 // A seek that lands clears any earlier deferred one: the newer intent wins.
670 *self.pending_seek.lock_safe() = None;
671
672 // The poll thread suppresses updates for 150ms after a seek, so without
673 // this a file ending inside that window would report the pre-seek time.
674 self.observed.lock_safe().record_position(position);
675
676 Ok(())
677 }
678
679 fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
680 let clamped = volume.clamp(0.0, 1.0);
681 debug!("[MpvBackend] Set volume to {}", clamped);
682
683 // MPV expects volume as percentage (0-100)
684 let mpv_volume = volume_to_percent(clamped as f64) as i64;
685
686 self.mpv
687 .set_property("volume", mpv_volume)
688 .map_err(|e| PlayerError {
689 message: format!("Failed to set volume: {:?}", e),
690 })?;
691
692 let mut state = self.state.lock_safe();
693 state.volume = clamped;
694
695 Ok(())
696 }
697
698 /// Current position — the live `time-pos`, or the last one observed while a
699 /// file was loaded.
700 ///
701 /// The fallback is the point: `time-pos` is a property of the *loaded* file,
702 /// so at EOF it stops resolving and a bare `unwrap_or(0.0)` reported 0:00 at
703 /// exactly the moment end-of-file handling asks where playback reached.
704 ///
705 /// TRACES: UR-005 | DR-130 | UT-121
706 fn position(&self) -> f64 {
707 let live = self.mpv.get_property::<f64>("time-pos").ok();
708 self.observed.lock_safe().position_or_last(live)
709 }
710
711 /// Total duration — live, or the last one observed. Unloaded at EOF for the
712 /// same reason as `position`.
713 ///
714 /// TRACES: UR-005 | DR-130 | UT-121
715 fn duration(&self) -> Option<f64> {
716 let live = self.mpv.get_property::<f64>("duration").ok();
717 self.observed.lock_safe().duration_or_last(live)
718 }
719
720 fn state(&self) -> PlayerState {
721 let state = self.state.lock_safe();
722
723 if let Some(ref media) = state.current_media {
724 let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
725 let position = self.position();
726 let duration = self.duration().unwrap_or(0.0);
727
728 if is_paused {
729 PlayerState::Paused {
730 media: media.clone(),
731 position,
732 duration,
733 }
734 } else {
735 PlayerState::Playing {
736 media: media.clone(),
737 position,
738 duration,
739 }
740 }
741 } else {
742 PlayerState::Idle
743 }
744 }
745
746 fn volume(&self) -> f32 {
747 let state = self.state.lock_safe();
748 state.volume
749 }
750
751 fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
752 info!("[MpvBackend] Applying audio settings");
753 self.audio_settings = settings.clone();
754
755 // Apply gapless playback
756 if settings.gapless_playback {
757 self.mpv
758 .set_property("gapless-audio", "yes")
759 .map_err(|e| PlayerError {
760 message: format!("Failed to enable gapless: {:?}", e),
761 })?;
762 } else {
763 self.mpv
764 .set_property("gapless-audio", "no")
765 .map_err(|e| PlayerError {
766 message: format!("Failed to disable gapless: {:?}", e),
767 })?;
768 }
769
770 // Audio filter chain: build a single lavfi graph combining the EQ
771 // peaking bands and (optionally) a dynamic loudness normalizer, and
772 // set the `af` property. An empty string clears all filters. Both
773 // features share one `af` graph because MPV exposes a single filter
774 // property. See docs/architecture/05-platform-backends.md and IR-020.
775 let af = build_af_filter(settings);
776 self.mpv
777 .set_property("af", af.as_str())
778 .map_err(|e| PlayerError {
779 message: format!("Failed to set audio filters: {:?}", e),
780 })?;
781
782 // TODO: Implement crossfade via MPV audio filters if needed
783
784 Ok(())
785 }
786
787 fn audio_settings(&self) -> AudioSettings {
788 self.audio_settings.clone()
789 }
790}
791
792/// Build the full MPV `af` (audio filter) value from the audio settings.
793///
794/// Combines the equalizer peaking bands and the loudness-normalization filter
795/// into a single `lavfi` graph, because MPV exposes one `af` property. The
796/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
797/// empty string when neither feature contributes a filter, which clears `af`.
798///
799/// TRACES: UR-027, UR-033 | IR-020, DR-036
800fn build_af_filter(settings: &AudioSettings) -> String {
801 let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
802 if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
803 entries.push(norm);
804 }
805
806 if entries.is_empty() {
807 return String::new();
808 }
809 format!("lavfi=[{}]", entries.join(","))
810}
811
812/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
813/// peaking) per band with a non-zero gain, e.g.
814/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
815/// is disabled or every gain is ~0. Gains are assumed already normalised by
816/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
817/// ignored.
818///
819/// TRACES: UR-027 | IR-020
820fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
821 if !enabled {
822 return Vec::new();
823 }
824 bands
825 .iter()
826 .zip(EQ_BANDS.iter())
827 .filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
828 .map(|(gain, freq)| {
829 // width_type=o → octave bandwidth; width=1 → one octave per band.
830 format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
831 })
832 .collect()
833}
834
835/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
836/// [`VolumeLevel::Normal`] (−14 LUFS) target, leaving −1.2 dB of headroom.
837const NORMALIZE_REF_PEAK: f32 = 0.87;
838/// Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).
839const NORMALIZE_REF_LUFS: f32 = -14.0;
840
841/// The loudness-normalization filter entry (unwrapped), or `None` when
842/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
843/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
844/// mode can produce on very dynamic material.
845///
846/// `dynaudnorm` targets a peak amplitude (`p`, linear 0–1), not a LUFS value,
847/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
848/// offset from the Normal reference is applied as a dB offset to the reference
849/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
850/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
851/// so loud presets never request full-scale.
852///
853/// TRACES: UR-033 | DR-036
854fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
855 if !enabled {
856 return None;
857 }
858 // LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
859 let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
860 let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
861 // 3 decimals is plenty for a peak target and keeps the filter string stable.
862 Some(format!("dynaudnorm=p={:.3}:g=15", peak))
863}
864
865impl Drop for MpvBackend {
866 fn drop(&mut self) {
867 info!("[MpvBackend] Shutting down");
868 // MPV will be automatically cleaned up
869 }
870}
871
872#[cfg(test)]
873mod af_filter_tests {
874 use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
875 use crate::settings::{AudioSettings, VolumeLevel};
876
877 fn settings() -> AudioSettings {
878 AudioSettings {
879 equalizer_enabled: false,
880 equalizer_bands: vec![0.0; 10],
881 normalize_volume: false,
882 ..AudioSettings::default()
883 }
884 }
885
886 /// Disabled EQ, or an all-zero curve, produces no EQ entries.
887 ///
888 /// TRACES: UR-027 | IR-020 | UT-083
889 #[test]
890 fn test_eq_entries_empty_when_disabled_or_flat() {
891 assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
892 assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
893 // Sub-threshold gains count as flat.
894 assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
895 }
896
897 /// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
898 /// centre frequency and gain, chained inside a single `lavfi` filter.
899 ///
900 /// TRACES: UR-027 | IR-020 | UT-084
901 #[test]
902 fn test_eq_filter_builds_lavfi_chain() {
903 // First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
904 let mut s = settings();
905 s.equalizer_enabled = true;
906 s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
907 let af = build_af_filter(&s);
908 assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
909 assert!(af.ends_with("]"));
910 assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
911 assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
912 // Only two bands are non-zero → exactly two peaking filters.
913 assert_eq!(af.matches("equalizer=").count(), 2);
914 }
915
916 /// Disabled normalization yields no filter entry; the combined `af` for a
917 /// fully default (all-off) settings is empty, which clears `af`.
918 ///
919 /// TRACES: UR-033 | DR-036 | UT-085
920 #[test]
921 fn test_normalize_disabled_produces_no_filter() {
922 assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
923 assert_eq!(build_af_filter(&settings()), "");
924 }
925
926 /// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
927 /// the peak preserves the Loud > Normal > Quiet ordering.
928 ///
929 /// TRACES: UR-033 | DR-036 | UT-086
930 #[test]
931 fn test_normalize_peak_preserves_preset_ordering() {
932 fn peak_of(entry: &str) -> f32 {
933 // "dynaudnorm=p=0.870:g=15" → 0.870
934 entry
935 .split("p=")
936 .nth(1)
937 .and_then(|s| s.split(':').next())
938 .and_then(|s| s.parse().ok())
939 .expect("parseable peak")
940 }
941
942 let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
943 let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
944 let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
945 for entry in [&loud, &normal, &quiet] {
946 assert!(
947 entry.starts_with("dynaudnorm="),
948 "dynaudnorm filter: {entry}"
949 );
950 }
951 assert!(
952 peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
953 "Loud {} > Normal {} > Quiet {}",
954 peak_of(&loud),
955 peak_of(&normal),
956 peak_of(&quiet),
957 );
958 // Every preset stays within the safe (0, 0.99] clamp.
959 for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
960 assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
961 }
962
963 let mut s = settings();
964 s.normalize_volume = true;
965 s.volume_level = VolumeLevel::Quiet;
966 let af = build_af_filter(&s);
967 assert!(af.starts_with("lavfi=["));
968 assert!(af.contains("dynaudnorm=p="));
969 }
970
971 /// EQ and normalization coexist in one `lavfi` graph, with the normalizer
972 /// placed after the EQ bands so it levels the post-EQ signal.
973 ///
974 /// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
975 #[test]
976 fn test_eq_and_normalize_combine_in_order() {
977 let mut s = settings();
978 s.equalizer_enabled = true;
979 s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
980 s.normalize_volume = true;
981 s.volume_level = VolumeLevel::Normal;
982 let af = build_af_filter(&s);
983
984 let eq_pos = af.find("equalizer=").expect("has EQ");
985 let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
986 assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
987 }
988}