Skip to main content

#
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        // Nor its deferred seek. A seek held for a file that is no longer the
    596        // one loading would be applied to this one by the `FileLoaded` handler
    597        // — so scrubbing near the end of a transcoded item, which re-opens the
    598        // stream, and then skipping to the next item before the reload finished
    599        // started the new item wherever the old one had been scrubbed to.
    600        // TRACES: UR-040, UR-005 | DR-253
    601        *self.pending_seek.lock_safe() = None;
    602
    603        // Load the media file
    604        self.mpv
    605            .command("loadfile", &[&stream_url])
    606            .map_err(|e| PlayerError {
    607                message: format!("Failed to load file: {:?}", e),
    608            })?;
    609
    610        debug!("[MpvBackend] Load command sent successfully");
    611        Ok(())
    612    }
    613
    614    fn play(&mut self) -> Result<(), PlayerError> {
    615        debug!("[MpvBackend] Play command");
    616
    617        self.mpv
    618            .set_property("pause", false)
    619            .map_err(|e| PlayerError {
    620                message: format!("Failed to play: {:?}", e),
    621            })?;
    622
    623        Ok(())
    624    }
    625
    626    fn pause(&mut self) -> Result<(), PlayerError> {
    627        debug!("[MpvBackend] Pause command");
    628
    629        self.mpv
    630            .set_property("pause", true)
    631            .map_err(|e| PlayerError {
    632                message: format!("Failed to pause: {:?}", e),
    633            })?;
    634
    635        Ok(())
    636    }
    637
    638    fn stop(&mut self) -> Result<(), PlayerError> {
    639        debug!("[MpvBackend] Stop command");
    640
    641        self.mpv.command("stop", &[]).map_err(|e| PlayerError {
    642            message: format!("Failed to stop: {:?}", e),
    643        })?;
    644
    645        // Stopping ends the seek's subject along with the playback.
    646        // TRACES: UR-040, UR-005 | DR-253
    647        *self.pending_seek.lock_safe() = None;
    648
    649        let mut state = self.state.lock_safe();
    650        state.current_media = None;
    651
    652        Ok(())
    653    }
    654
    655    fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
    656        debug!("[MpvBackend] Seek to {} seconds", position);
    657
    658        // Record the seek time to suppress position updates briefly
    659        let now = SystemTime::now()
    660            .duration_since(UNIX_EPOCH)
    661            .unwrap()
    662            .as_millis() as u64;
    663        self.last_seek_time.store(now, Ordering::Relaxed);
    664
    665        // `time-pos` only resolves while a file is loaded. `loadfile` is
    666        // asynchronous, so a seek issued straight after a reload — resume, or a
    667        // transcoded seek — lands in a window where this fails, and dropping it
    668        // there is what makes the stream play from zero instead of the position
    669        // that was asked for. Hold it and let `FileLoaded` apply it.
    670        // TRACES: UR-040, UR-005 | DR-241
    671        if let Err(e) = self.mpv.set_property("time-pos", position) {
    672            debug!(
    673                "[MpvBackend] seek to {position} deferred until the file loads ({:?})",
    674                e
    675            );
    676            *self.pending_seek.lock_safe() = Some(position);
    677            self.observed.lock_safe().record_position(position);
    678            return Ok(());
    679        }
    680
    681        // A seek that lands clears any earlier deferred one: the newer intent wins.
    682        *self.pending_seek.lock_safe() = None;
    683
    684        // The poll thread suppresses updates for 150ms after a seek, so without
    685        // this a file ending inside that window would report the pre-seek time.
    686        self.observed.lock_safe().record_position(position);
    687
    688        Ok(())
    689    }
    690
    691    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
    692        let clamped = volume.clamp(0.0, 1.0);
    693        debug!("[MpvBackend] Set volume to {}", clamped);
    694
    695        // MPV expects volume as percentage (0-100)
    696        let mpv_volume = volume_to_percent(clamped as f64) as i64;
    697
    698        self.mpv
    699            .set_property("volume", mpv_volume)
    700            .map_err(|e| PlayerError {
    701                message: format!("Failed to set volume: {:?}", e),
    702            })?;
    703
    704        let mut state = self.state.lock_safe();
    705        state.volume = clamped;
    706
    707        Ok(())
    708    }
    709
    710    /// Current position — the live `time-pos`, or the last one observed while a
    711    /// file was loaded.
    712    ///
    713    /// The fallback is the point: `time-pos` is a property of the *loaded* file,
    714    /// so at EOF it stops resolving and a bare `unwrap_or(0.0)` reported 0:00 at
    715    /// exactly the moment end-of-file handling asks where playback reached.
    716    ///
    717    /// TRACES: UR-005 | DR-130 | UT-121
    718    fn position(&self) -> f64 {
    719        let live = self.mpv.get_property::<f64>("time-pos").ok();
    720        self.observed.lock_safe().position_or_last(live)
    721    }
    722
    723    /// Total duration — live, or the last one observed. Unloaded at EOF for the
    724    /// same reason as `position`.
    725    ///
    726    /// TRACES: UR-005 | DR-130 | UT-121
    727    fn duration(&self) -> Option<f64> {
    728        let live = self.mpv.get_property::<f64>("duration").ok();
    729        self.observed.lock_safe().duration_or_last(live)
    730    }
    731
    732    fn state(&self) -> PlayerState {
    733        let state = self.state.lock_safe();
    734
    735        if let Some(ref media) = state.current_media {
    736            let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
    737            let position = self.position();
    738            let duration = self.duration().unwrap_or(0.0);
    739
    740            if is_paused {
    741                PlayerState::Paused {
    742                    media: media.clone(),
    743                    position,
    744                    duration,
    745                }
    746            } else {
    747                PlayerState::Playing {
    748                    media: media.clone(),
    749                    position,
    750                    duration,
    751                }
    752            }
    753        } else {
    754            PlayerState::Idle
    755        }
    756    }
    757
    758    fn volume(&self) -> f32 {
    759        let state = self.state.lock_safe();
    760        state.volume
    761    }
    762
    763    fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
    764        info!("[MpvBackend] Applying audio settings");
    765        self.audio_settings = settings.clone();
    766
    767        // Apply gapless playback
    768        if settings.gapless_playback {
    769            self.mpv
    770                .set_property("gapless-audio", "yes")
    771                .map_err(|e| PlayerError {
    772                    message: format!("Failed to enable gapless: {:?}", e),
    773                })?;
    774        } else {
    775            self.mpv
    776                .set_property("gapless-audio", "no")
    777                .map_err(|e| PlayerError {
    778                    message: format!("Failed to disable gapless: {:?}", e),
    779                })?;
    780        }
    781
    782        // Audio filter chain: build a single lavfi graph combining the EQ
    783        // peaking bands and (optionally) a dynamic loudness normalizer, and
    784        // set the `af` property. An empty string clears all filters. Both
    785        // features share one `af` graph because MPV exposes a single filter
    786        // property. See docs/architecture/05-platform-backends.md and IR-020.
    787        let af = build_af_filter(settings);
    788        self.mpv
    789            .set_property("af", af.as_str())
    790            .map_err(|e| PlayerError {
    791                message: format!("Failed to set audio filters: {:?}", e),
    792            })?;
    793
    794        // TODO: Implement crossfade via MPV audio filters if needed
    795
    796        Ok(())
    797    }
    798
    799    fn audio_settings(&self) -> AudioSettings {
    800        self.audio_settings.clone()
    801    }
    802}
    803
    804/// Build the full MPV `af` (audio filter) value from the audio settings.
    805///
    806/// Combines the equalizer peaking bands and the loudness-normalization filter
    807/// into a single `lavfi` graph, because MPV exposes one `af` property. The
    808/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
    809/// empty string when neither feature contributes a filter, which clears `af`.
    810///
    811/// TRACES: UR-027, UR-033 | IR-020, DR-036
    812fn build_af_filter(settings: &AudioSettings) -> String {
    813    let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
    814    if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
    815        entries.push(norm);
    816    }
    817
    818    if entries.is_empty() {
    819        return String::new();
    820    }
    821    format!("lavfi=[{}]", entries.join(","))
    822}
    823
    824/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
    825/// peaking) per band with a non-zero gain, e.g.
    826/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
    827/// is disabled or every gain is ~0. Gains are assumed already normalised by
    828/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
    829/// ignored.
    830///
    831/// TRACES: UR-027 | IR-020
    832fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
    833    if !enabled {
    834        return Vec::new();
    835    }
    836    bands
    837        .iter()
    838        .zip(EQ_BANDS.iter())
    839        .filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
    840        .map(|(gain, freq)| {
    841            // width_type=o → octave bandwidth; width=1 → one octave per band.
    842            format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
    843        })
    844        .collect()
    845}
    846
    847/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
    848/// [`VolumeLevel::Normal`] (−14 LUFS) target, leaving −1.2 dB of headroom.
    849const NORMALIZE_REF_PEAK: f32 = 0.87;
    850/// Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).
    851const NORMALIZE_REF_LUFS: f32 = -14.0;
    852
    853/// The loudness-normalization filter entry (unwrapped), or `None` when
    854/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
    855/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
    856/// mode can produce on very dynamic material.
    857///
    858/// `dynaudnorm` targets a peak amplitude (`p`, linear 0–1), not a LUFS value,
    859/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
    860/// offset from the Normal reference is applied as a dB offset to the reference
    861/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
    862/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
    863/// so loud presets never request full-scale.
    864///
    865/// TRACES: UR-033 | DR-036
    866fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
    867    if !enabled {
    868        return None;
    869    }
    870    // LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
    871    let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
    872    let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
    873    // 3 decimals is plenty for a peak target and keeps the filter string stable.
    874    Some(format!("dynaudnorm=p={:.3}:g=15", peak))
    875}
    876
    877impl Drop for MpvBackend {
    878    fn drop(&mut self) {
    879        info!("[MpvBackend] Shutting down");
    880        // MPV will be automatically cleaned up
    881    }
    882}
    883
    884#[cfg(test)]
    885mod af_filter_tests {
    886    use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
    887    use crate::settings::{AudioSettings, VolumeLevel};
    888
    889    fn settings() -> AudioSettings {
    890        AudioSettings {
    891            equalizer_enabled: false,
    892            equalizer_bands: vec![0.0; 10],
    893            normalize_volume: false,
    894            ..AudioSettings::default()
    895        }
    896    }
    897
    898    /// Disabled EQ, or an all-zero curve, produces no EQ entries.
    899    ///
    900    /// TRACES: UR-027 | IR-020 | UT-083
    901    #[test]
    902    fn test_eq_entries_empty_when_disabled_or_flat() {
    903        assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
    904        assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
    905        // Sub-threshold gains count as flat.
    906        assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
    907    }
    908
    909    /// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
    910    /// centre frequency and gain, chained inside a single `lavfi` filter.
    911    ///
    912    /// TRACES: UR-027 | IR-020 | UT-084
    913    #[test]
    914    fn test_eq_filter_builds_lavfi_chain() {
    915        // First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
    916        let mut s = settings();
    917        s.equalizer_enabled = true;
    918        s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
    919        let af = build_af_filter(&s);
    920        assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
    921        assert!(af.ends_with("]"));
    922        assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
    923        assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
    924        // Only two bands are non-zero → exactly two peaking filters.
    925        assert_eq!(af.matches("equalizer=").count(), 2);
    926    }
    927
    928    /// Disabled normalization yields no filter entry; the combined `af` for a
    929    /// fully default (all-off) settings is empty, which clears `af`.
    930    ///
    931    /// TRACES: UR-033 | DR-036 | UT-085
    932    #[test]
    933    fn test_normalize_disabled_produces_no_filter() {
    934        assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
    935        assert_eq!(build_af_filter(&settings()), "");
    936    }
    937
    938    /// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
    939    /// the peak preserves the Loud > Normal > Quiet ordering.
    940    ///
    941    /// TRACES: UR-033 | DR-036 | UT-086
    942    #[test]
    943    fn test_normalize_peak_preserves_preset_ordering() {
    944        fn peak_of(entry: &str) -> f32 {
    945            // "dynaudnorm=p=0.870:g=15" → 0.870
    946            entry
    947                .split("p=")
    948                .nth(1)
    949                .and_then(|s| s.split(':').next())
    950                .and_then(|s| s.parse().ok())
    951                .expect("parseable peak")
    952        }
    953
    954        let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
    955        let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
    956        let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
    957        for entry in [&loud, &normal, &quiet] {
    958            assert!(
    959                entry.starts_with("dynaudnorm="),
    960                "dynaudnorm filter: {entry}"
    961            );
    962        }
    963        assert!(
    964            peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
    965            "Loud {} > Normal {} > Quiet {}",
    966            peak_of(&loud),
    967            peak_of(&normal),
    968            peak_of(&quiet),
    969        );
    970        // Every preset stays within the safe (0, 0.99] clamp.
    971        for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
    972            assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
    973        }
    974
    975        let mut s = settings();
    976        s.normalize_volume = true;
    977        s.volume_level = VolumeLevel::Quiet;
    978        let af = build_af_filter(&s);
    979        assert!(af.starts_with("lavfi=["));
    980        assert!(af.contains("dynaudnorm=p="));
    981    }
    982
    983    /// EQ and normalization coexist in one `lavfi` graph, with the normalizer
    984    /// placed after the EQ bands so it levels the post-EQ signal.
    985    ///
    986    /// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
    987    #[test]
    988    fn test_eq_and_normalize_combine_in_order() {
    989        let mut s = settings();
    990        s.equalizer_enabled = true;
    991        s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
    992        s.normalize_volume = true;
    993        s.volume_level = VolumeLevel::Normal;
    994        let af = build_af_filter(&s);
    995
    996        let eq_pos = af.find("equalizer=").expect("has EQ");
    997        let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
    998        assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
    999    }
    1000}