use super::backend::{PlayerBackend, PlayerError}; use super::events::{PlayerEventEmitter, PlayerStatusEvent}; use super::media::{MediaItem, MediaSource}; use super::state::PlayerState; use super::stream_end::ObservedTime; use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter}; use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS}; use crate::utils::conversions::{seconds_to_ticks, volume_to_percent}; use crate::utils::lock::MutexSafe; use libmpv::Mpv; use log::{debug, error, info, warn}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::Mutex as TokioMutex; /// MPV-based player backend for Linux /// /// Uses libmpv for audio playback with full control over playback state, /// position tracking, and event handling. pub struct MpvBackend { mpv: Arc, state: Arc>, event_emitter: Option>, audio_settings: AudioSettings, playback_reporter: Arc>>, position_throttler: Arc, last_seek_time: Arc, /// 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>, /// A seek that arrived before MPV had a file to seek in. /// /// `loadfile` is asynchronous: it returns as soon as the command is queued, /// so `time-pos` is not yet a resolvable property and setting it fails. A /// seek issued in that window used to be dropped on the floor, and the two /// callers that do exactly this are the ones a viewer notices — resume, and /// a transcoded seek, both of which re-open the stream and then ask for a /// position. The stream reloaded and played from zero. /// /// Held here and applied by the `FileLoaded` arm. /// /// TRACES: UR-040, UR-005 | DR-241 pending_seek: Arc>>, } struct InternalState { current_media: Option, volume: f32, } /// The audio output mpv should use on Windows: WASAPI, the only one it ships /// there. Nothing to probe — and spawning `pactl` from a GUI app on Windows /// would at best fail and at worst flash a console window. /// /// TRACES: UR-004 | DR-237 #[cfg(target_os = "windows")] fn detect_audio_system() -> String { "wasapi".to_string() } /// Detect which audio system is available on the system #[cfg(not(target_os = "windows"))] fn detect_audio_system() -> String { use std::process::Command; info!("[MpvBackend] Detecting audio system..."); // Try PulseAudio/PipeWire first (most common on modern Linux) if let Ok(output) = Command::new("pactl").arg("info").output() { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); if stdout.contains("PipeWire") { info!("[MpvBackend] Detected PipeWire (with PulseAudio compatibility)"); return "pulse".to_string(); } else if stdout.contains("PulseAudio") { info!("[MpvBackend] Detected PulseAudio"); return "pulse".to_string(); } } } // Try detecting PipeWire directly if let Ok(output) = Command::new("pw-cli").arg("info").arg("0").output() { if output.status.success() { info!("[MpvBackend] Detected PipeWire"); return "pulse".to_string(); // PipeWire works with pulse driver } } // Check if ALSA is available if std::path::Path::new("/proc/asound/cards").exists() { info!("[MpvBackend] Falling back to ALSA"); return "alsa".to_string(); } // Default fallback warn!("[MpvBackend] Could not detect audio system, using 'auto'"); "auto".to_string() } /// Helper to get stream URL from MediaItem fn get_stream_url(media: &MediaItem) -> String { match &media.source { MediaSource::Remote { stream_url, .. } => stream_url.clone(), // A Windows path is not a URL (`file://C:\...` is malformed); mpv // takes the native path as it is. Safe to pass verbatim because it goes // to mpv as one argv element (DR-298), not through a command string. // TRACES: UR-004, UR-071 | DR-237 MediaSource::Local { file_path, .. } if cfg!(target_os = "windows") => { file_path.to_string_lossy().into_owned() } MediaSource::Local { file_path, .. } => { format!("file://{}", file_path.to_string_lossy()) } MediaSource::DirectUrl { url } => url.clone(), } } /// The mpv handle of the backend this process created, for the video surface. /// /// A `OnceLock` rather than a field reached through `PlayerBackend`, because the /// trait is cross-platform and a raw mpv pointer is not something every backend /// should have to pretend to have. Stored as `usize` because a raw pointer is /// neither `Send` nor `Sync`; the only consumer is the GTK main thread, which is /// also where mpv was created. /// /// Written once at construction and never cleared: the backend outlives the /// window, so there is no window in which this could dangle while a surface is /// still using it. /// /// TRACES: UR-080 | DR-231 static MPV_HANDLE: std::sync::OnceLock = std::sync::OnceLock::new(); /// The registered handle, or null if no MPV backend was created (initialisation /// can fail, and the app falls back to a no-op backend rather than dying). /// /// TRACES: UR-080 | DR-231 // Only the Linux video surface reads it until Windows gets one (DR-237). #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub fn registered_handle() -> *mut libmpv_sys::mpv_handle { MPV_HANDLE .get() .map(|p| *p as *mut libmpv_sys::mpv_handle) .unwrap_or(std::ptr::null_mut()) } /// How mpv shows video on this platform. /// /// TRACES: UR-080 | DR-231, DR-237 #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum VideoOutput { /// No picture: audio-only playback, or nowhere to draw. Off, /// Linux: frames through the render API into the GTK surface beneath the /// webview (`video_surface`). RenderApi, /// Windows: mpv renders as a child of the app's own window (`wid`, set /// before initialisation), beneath the transparent WebView2 — the /// arrangement tauri-plugin-libmpv ships on Windows. Window(i64), } impl VideoOutput { /// Runtime options for this output. `wid` is not among them: it only takes /// effect before initialisation, so the constructor sets it separately. pub(crate) fn options(&self) -> Vec<(&'static str, String)> { match self { VideoOutput::Off => vec![("video", "no".to_string())], VideoOutput::RenderApi => vec![("vo", "libmpv".to_string())], VideoOutput::Window(_) => [ // libplacebo's renderer, with the classic one as fallback for a // build or GPU that lacks it. ("vo", "gpu-next,gpu"), // mpv is a surface here, not a player: the app's controls are // drawn over it, so its own controller and bindings must not // answer clicks, keys or the cursor. ("osc", "no"), ("input-default-bindings", "no"), ("input-vo-keyboard", "no"), ("input-cursor", "no"), ("cursor-autohide", "no"), ] .into_iter() .map(|(k, v)| (k, v.to_string())) .collect(), } } } /// Decide the video output from whether native video is on, the platform, and /// the app window's handle (Windows only). /// /// TRACES: UR-080 | DR-231, DR-237 | UT-274 pub(crate) fn video_output(native: bool, is_windows: bool, window: Option) -> VideoOutput { match (native, is_windows, window) { (false, _, _) => VideoOutput::Off, (true, true, Some(wid)) => VideoOutput::Window(wid), // No handle: mpv would open a top-level window of its own. (true, true, None) => VideoOutput::Off, (true, false, _) => VideoOutput::RenderApi, } } impl MpvBackend { /// Create a new MPV backend /// /// `video_window` is the app window's native handle (an HWND), which mpv /// draws video into on Windows; `None` elsewhere. pub fn new( event_emitter: Option>, playback_reporter: Arc>>, position_throttler: Arc, video_window: Option, ) -> Result { info!("[MpvBackend] Initializing MPV backend..."); // MPV requires LC_NUMERIC to be set to "C" locale // Set it before initializing MPV, then restore it after use std::ffi::CString; unsafe { let c_locale = CString::new("C").unwrap(); libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr()); } let output = video_output( super::native_video::enabled(), cfg!(target_os = "windows"), video_window, ); if super::native_video::enabled() && output == VideoOutput::Off { error!("[MpvBackend] no window handle to draw video into; video will have no picture"); } // `wid` only takes effect before initialisation. TRACES: UR-080 | DR-237 let mpv = Mpv::with_initializer(|init| { if let VideoOutput::Window(wid) = output { init.set_property("wid", wid)?; } Ok(()) }) .map_err(|e| PlayerError { message: format!("Failed to initialize MPV: {:?}", e), })?; // TRACES: UR-012 | DR-299 super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?; // Detect and configure audio output let audio_driver = detect_audio_system(); info!( "[MpvBackend] Configuring audio output driver: {}", audio_driver ); mpv.set_property("ao", audio_driver.as_str()) .map_err(|e| PlayerError { message: format!( "Failed to set audio output to '{}': {:?}. Make sure audio system is working.", audio_driver, e ), })?; // Enable verbose logging for audio initialization mpv.set_property("msg-level", "all=warn,ao=debug") .unwrap_or_else(|e| { warn!("[MpvBackend] Warning: Could not set MPV log level: {:?}", e); }); // Configure MPV for audio playback mpv.set_property("audio-display", "no") .map_err(|e| PlayerError { message: format!("Failed to configure MPV audio-display: {:?}", e), })?; // Video is disabled unless this process is drawing it. // // Linux video went through the webview until DR-235, and decoding it // here too would have burned a core for a picture nobody saw — hence // `video: no`. With native video, mpv needs the decoder *and* an output // that draws where the app wants it: the render API on Linux (the default // would open a window of its own), the app's window on Windows. // // Set at construction because mpv resolves the video output when it // initialises; flipping it later does not re-open one. // // TRACES: UR-080 | DR-231, DR-235, DR-237 for (name, value) in output.options() { mpv.set_property(name, value.as_str()) .map_err(|e| PlayerError { message: format!("Failed to set {name}={value}: {:?}", e), })?; } info!("[MpvBackend] video output: {:?}", output); // Set volume to 100% (we'll control via MPV's volume property) mpv.set_property("volume", 100i64) .map_err(|e| PlayerError { message: format!("Failed to set initial volume: {:?}", e), })?; // Survive a flaky connection instead of dying on it. Without these, // ffmpeg's HTTP demuxer gives up the moment a read fails and MPV raises // EndFile(ERROR) — a blip on wifi kills the track outright. Reconnecting // in the demuxer handles the common case entirely below our level, so // most outages never reach the recovery in `player_recover_stream`. // // Non-fatal: these are ffmpeg-side options whose availability varies with // the libmpv/ffmpeg build, and losing resilience is not a reason to // refuse to play anything (graceful backend init, CLAUDE.md). mpv.set_property( "stream-lavf-o", "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5", ) .unwrap_or_else(|e| { warn!( "[MpvBackend] Could not enable stream reconnection: {:?} — \ playback will not survive network interruptions", e ); }); mpv.set_property("network-timeout", 15i64) .unwrap_or_else(|e| { warn!("[MpvBackend] Could not set network timeout: {:?}", e); }); let state = Arc::new(Mutex::new(InternalState { current_media: None, volume: 1.0, })); let backend = MpvBackend { mpv: { let mpv = Arc::new(mpv); // Publish the handle for the video surface (DR-231). Ignores a // second call: only one MPV backend is ever constructed, and a // failed re-init must not replace a live handle. let _ = MPV_HANDLE.set(mpv.ctx.as_ptr() as usize); mpv }, state, event_emitter, audio_settings: AudioSettings::default(), playback_reporter, position_throttler, last_seek_time: Arc::new(AtomicU64::new(0)), pending_seek: Arc::new(Mutex::new(None)), observed: Arc::new(Mutex::new(ObservedTime::default())), }; // Start event loop in background thread backend.start_event_loop(); info!("[MpvBackend] Initialized successfully"); Ok(backend) } /// Start the MPV event loop in a background thread fn start_event_loop(&self) { let mpv = self.mpv.clone(); let event_emitter = self.event_emitter.clone(); let state = self.state.clone(); let reporter = self.playback_reporter.clone(); let throttler = self.position_throttler.clone(); let pending_seek_for_events = self.pending_seek.clone(); std::thread::spawn(move || { info!("[MpvBackend] Event loop started"); let mut ev_ctx = mpv.create_event_context(); ev_ctx.disable_deprecated_events().unwrap_or_else(|e| { error!("[MpvBackend] Failed to disable deprecated events: {:?}", e); }); // libmpv delivers PropertyChange only for properties registered // here. Every name matched in the loop below needs a line in this // block or its handler is unreachable — an omission that reads as // working code, because the handler is sitting right there. // UT-218 holds the two lists together. // // `pause` drives the play/pause control: the UI consumes // StateChanged rather than tracking playback itself, per the // one-directional state rule. Unobserved, the event never came and // the button never moved. Invisible until native video shipped, // because the (since deleted) webview