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