Skip to main content

jellytau_lib/player/
mpv_player.rs

1//! [`MediaPlayer`] over libmpv.
2//!
3//! The point of difference from `MpvBackend` is [`MpvPlayer::open`]: the start
4//! position is applied **at load time**, via mpv's own `start` option, instead
5//! of being seeked to afterwards. `loadfile` is asynchronous, so a seek issued
6//! after it targets a player that has nothing loaded, fails, and — under the old
7//! contract — was discarded. That is DR-241, and it is why resume and transcoded
8//! skip both played from zero.
9//!
10//! A seek arriving during [`Phase::Opening`] is held and applied when the file
11//! loads, so no caller has to know where that window begins or ends.
12//!
13//! TRACES: UR-081, UR-040, UR-005 | DR-244
14
15#![allow(dead_code)] // Wired to PlayerController in DR-245.
16
17use std::sync::{Arc, Mutex};
18use std::time::Duration;
19
20use libmpv::Mpv;
21use log::{debug, info, warn};
22
23use super::backend::PlayerError;
24use super::media_player::{
25    duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
26};
27use crate::utils::lock::MutexSafe;
28
29/// State the event thread writes and the caller reads.
30#[derive(Debug)]
31struct Shared {
32    phase: Phase,
33    position: Duration,
34    duration: Option<Duration>,
35    seekable: bool,
36    /// A seek that arrived while opening. Applied on `FileLoaded`.
37    deferred_seek: Option<Duration>,
38    /// Cleared by `close()`, so an open still in flight cannot come back to life
39    /// and start playing after the caller has stopped it.
40    open_generation: u64,
41}
42
43impl Default for Shared {
44    fn default() -> Self {
45        Self {
46            phase: Phase::Idle,
47            position: Duration::ZERO,
48            duration: None,
49            seekable: false,
50            deferred_seek: None,
51            open_generation: 0,
52        }
53    }
54}
55
56pub struct MpvPlayer {
57    mpv: Arc<Mpv>,
58    shared: Arc<Mutex<Shared>>,
59    volume: f32,
60    muted: bool,
61    rate: f64,
62    audio_track: Option<i32>,
63    subtitle_track: Option<i32>,
64}
65
66/// How the engine should talk to the machine.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Output {
69    /// Real audio and video. What the app uses.
70    Real,
71    /// No audio device, no window. What conformance uses, so the suite can run
72    /// on a headless runner without claiming the user's speakers.
73    Null,
74}
75
76impl MpvPlayer {
77    pub fn new(output: Output) -> Result<Self, PlayerError> {
78        // mpv refuses to start under a non-C LC_NUMERIC, and anything that has
79        // initialised GTK before us will have set one.
80        unsafe {
81            let c = std::ffi::CString::new("C").unwrap();
82            libc::setlocale(libc::LC_NUMERIC, c.as_ptr());
83        }
84
85        let mpv = Mpv::new().map_err(|e| PlayerError {
86            message: format!("mpv_create failed: {e:?}"),
87        })?;
88        // TRACES: UR-012 | DR-299
89        super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
90
91        let set = |k: &str, v: &str| {
92            if let Err(e) = mpv.set_property(k, v) {
93                warn!("[MpvPlayer] could not set {k}={v}: {e:?}");
94            }
95        };
96        match output {
97            Output::Real => {
98                set("vo", "libmpv");
99            }
100            Output::Null => {
101                set("ao", "null");
102                set("vo", "null");
103            }
104        }
105        set("msg-level", "all=warn");
106        // Survive a blip rather than ending the item on it.
107        set(
108            "stream-lavf-o",
109            "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
110        );
111
112        let player = Self {
113            mpv: Arc::new(mpv),
114            shared: Arc::new(Mutex::new(Shared::default())),
115            volume: 1.0,
116            muted: false,
117            rate: 1.0,
118            audio_track: None,
119            subtitle_track: None,
120        };
121        player.spawn_events();
122        Ok(player)
123    }
124
125    fn spawn_events(&self) {
126        let mpv = self.mpv.clone();
127        let shared = self.shared.clone();
128
129        std::thread::spawn(move || {
130            let mut ev = mpv.create_event_context();
131            let _ = ev.disable_deprecated_events();
132            // Every property matched below must be observed, or libmpv never
133            // delivers it and the handler is unreachable (DR-239).
134            for prop in ["pause", "eof-reached"] {
135                if let Err(e) = ev.observe_property(prop, libmpv::Format::Flag, 0) {
136                    warn!("[MpvPlayer] could not observe {prop}: {e:?}");
137                }
138            }
139
140            loop {
141                match ev.wait_event(0.25) {
142                    Some(Ok(libmpv::events::Event::FileLoaded)) => {
143                        let deferred = {
144                            let mut s = shared.lock_safe();
145                            // Closed while opening: do not start.
146                            if s.phase == Phase::Idle {
147                                continue;
148                            }
149                            s.duration = mpv
150                                .get_property::<f64>("duration")
151                                .ok()
152                                .and_then(duration_from_secs);
153                            s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true);
154                            s.phase = Phase::Playing;
155                            s.deferred_seek.take()
156                        };
157                        if let Some(to) = deferred {
158                            debug!("[MpvPlayer] applying deferred seek to {to:?}");
159                            if let Err(e) = mpv.set_property("time-pos", to.as_secs_f64()) {
160                                warn!("[MpvPlayer] deferred seek failed: {e:?}");
161                            }
162                        }
163                    }
164                    Some(Ok(libmpv::events::Event::PropertyChange { name: "pause", .. })) => {
165                        if let Ok(paused) = mpv.get_property::<bool>("pause") {
166                            let mut s = shared.lock_safe();
167                            if s.phase.has_media() {
168                                s.phase = if paused {
169                                    Phase::Paused
170                                } else {
171                                    Phase::Playing
172                                };
173                            }
174                        }
175                    }
176                    Some(Ok(libmpv::events::Event::EndFile(reason))) => {
177                        let mut s = shared.lock_safe();
178                        // 0 = EOF. Anything else is a stop, a quit or an error,
179                        // and must not read as "the item finished".
180                        s.phase = if reason == 0 {
181                            Phase::Ended
182                        } else {
183                            Phase::Idle
184                        };
185                    }
186                    Some(Ok(libmpv::events::Event::Shutdown)) => break,
187                    _ => {}
188                }
189
190                if let Ok(pos) = mpv.get_property::<f64>("time-pos") {
191                    let mut s = shared.lock_safe();
192                    if s.phase.has_media() && s.deferred_seek.is_none() {
193                        s.position = Duration::from_secs_f64(pos.max(0.0));
194                    }
195                }
196            }
197        });
198    }
199}
200
201impl MediaPlayer for MpvPlayer {
202    fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
203        {
204            let mut s = self.shared.lock_safe();
205            *s = Shared {
206                phase: Phase::Opening,
207                open_generation: s.open_generation + 1,
208                ..Shared::default()
209            };
210            // Report the requested position immediately, so a caller reading
211            // back during the open sees where it asked to be rather than zero.
212            s.position = req.start;
213        }
214
215        // The whole point. `start` is applied by mpv as it opens the file, so
216        // there is no window in which the position can be asked for and lost.
217        let start = if req.start.is_zero() {
218            "none".to_string()
219        } else {
220            format!("{:.3}", req.start.as_secs_f64())
221        };
222        self.mpv
223            .set_property("start", start.as_str())
224            .map_err(|e| PlayerError {
225                message: format!("could not set start position: {e:?}"),
226            })?;
227        self.mpv
228            .set_property("pause", !req.autoplay)
229            .map_err(|e| PlayerError {
230                message: format!("could not set pause: {e:?}"),
231            })?;
232
233        info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
234        // TRACES: UR-003, UR-004 | DR-298
235        super::mpv_command::command(&self.mpv, &["loadfile", &req.selection.url, "replace"])
236            .map_err(|e| PlayerError {
237                message: format!("loadfile failed: {e}"),
238            })?;
239        Ok(())
240    }
241
242    fn play(&mut self) -> Result<(), PlayerError> {
243        self.mpv
244            .set_property("pause", false)
245            .map_err(|e| PlayerError {
246                message: format!("play failed: {e:?}"),
247            })?;
248        let mut s = self.shared.lock_safe();
249        if s.phase.has_media() && s.phase != Phase::Opening {
250            s.phase = Phase::Playing;
251        }
252        Ok(())
253    }
254
255    fn pause(&mut self) -> Result<(), PlayerError> {
256        self.mpv
257            .set_property("pause", true)
258            .map_err(|e| PlayerError {
259                message: format!("pause failed: {e:?}"),
260            })?;
261        let mut s = self.shared.lock_safe();
262        if s.phase.has_media() && s.phase != Phase::Opening {
263            s.phase = Phase::Paused;
264        }
265        Ok(())
266    }
267
268    fn close(&mut self) -> Result<(), PlayerError> {
269        // State first: an open still in flight checks this on FileLoaded and
270        // must not proceed to play after the caller has stopped it.
271        {
272            let mut s = self.shared.lock_safe();
273            *s = Shared {
274                open_generation: s.open_generation,
275                ..Shared::default()
276            };
277        }
278        // Idempotent: stopping an already-stopped mpv is not an error worth
279        // propagating, and callers legitimately close twice on teardown.
280        if let Err(e) = super::mpv_command::command(&self.mpv, &["stop"]) {
281            debug!("[MpvPlayer] stop on an idle player: {e}");
282        }
283        Ok(())
284    }
285
286    fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
287        {
288            let mut s = self.shared.lock_safe();
289            match s.phase {
290                // Held, not dropped. The caller cannot see this window.
291                Phase::Opening => {
292                    s.deferred_seek = Some(to);
293                    s.position = to;
294                    return Ok(());
295                }
296                Phase::Idle | Phase::Failed(_) => {
297                    return Err(PlayerError {
298                        message: "seek with nothing open".to_string(),
299                    })
300                }
301                _ => s.position = to,
302            }
303        }
304        self.mpv
305            .set_property("time-pos", to.as_secs_f64())
306            .map_err(|e| PlayerError {
307                message: format!("seek failed: {e:?}"),
308            })
309    }
310
311    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
312        let clamped = volume.clamp(0.0, 1.0);
313        self.volume = clamped;
314        self.mpv
315            .set_property("volume", (clamped as f64) * 100.0)
316            .map_err(|e| PlayerError {
317                message: format!("set_volume failed: {e:?}"),
318            })
319    }
320
321    fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
322        self.muted = muted;
323        self.mpv
324            .set_property("mute", muted)
325            .map_err(|e| PlayerError {
326                message: format!("set_muted failed: {e:?}"),
327            })
328    }
329
330    fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
331        self.rate = rate;
332        self.mpv
333            .set_property("speed", rate)
334            .map_err(|e| PlayerError {
335                message: format!("set_rate failed: {e:?}"),
336            })
337    }
338
339    fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
340        self.audio_track = index;
341        let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
342        self.mpv
343            .set_property("aid", value.as_str())
344            .map_err(|e| PlayerError {
345                message: format!("select_audio_track failed: {e:?}"),
346            })
347    }
348
349    fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
350        self.subtitle_track = index;
351        let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
352        self.mpv
353            .set_property("sid", value.as_str())
354            .map_err(|e| PlayerError {
355                message: format!("select_subtitle_track failed: {e:?}"),
356            })
357    }
358
359    fn snapshot(&self) -> PlaybackSnapshot {
360        let s = self.shared.lock_safe();
361        PlaybackSnapshot {
362            phase: s.phase.clone(),
363            position: s.position,
364            duration: s.duration,
365            seekable: s.seekable,
366            volume: self.volume,
367            muted: self.muted,
368            rate: self.rate,
369            audio_track: self.audio_track,
370            subtitle_track: self.subtitle_track,
371        }
372    }
373
374    fn capabilities(&self) -> Capabilities {
375        Capabilities {
376            video: true,
377            audio_settings: true,
378            subtitle_switching: true,
379            audio_track_switching: true,
380            // mpv's HLS demuxer cannot make the server transcode from a new
381            // offset, so a transcoded seek must re-open the stream.
382            seeks_transcoded_in_place: false,
383        }
384    }
385}