mpv_engine/engine.rs
1use std::sync::atomic::{AtomicU64, Ordering};
2use std::sync::{Arc, Once};
3
4use parking_lot::Mutex;
5use rsmpv::{EndFileReason, Event, Format, Mpv, PropertyData, sys};
6
7use crate::error::{Error, Result, describe_code};
8use crate::render::{
9 GlRender, GlRenderOptions, ProcAddressFn, RenderBackend, RenderKind, SwRender,
10};
11
12/// Force `LC_NUMERIC=C` exactly once before the first `mpv_create`. mpv
13/// refuses to work under a comma-decimal locale (its option/number parsing
14/// breaks), and toolkits like GTK call `setlocale()` from the environment
15/// during init — so this must run even when the host app never touches
16/// locale itself. Both parent projects carried this guard independently;
17/// it lives here now.
18///
19/// The category constant comes from `libc` because its value is
20/// platform-specific: `LC_NUMERIC` is 1 on glibc but 4 on BSD/macOS and
21/// Windows — a hardcoded 1 would silently set `LC_COLLATE` there.
22fn ensure_c_numeric_locale() {
23 static ONCE: Once = Once::new();
24 ONCE.call_once(|| unsafe {
25 libc::setlocale(libc::LC_NUMERIC, c"C".as_ptr());
26 });
27}
28
29/// Playback lifecycle notifications drained by [`Engine::pump_events`].
30///
31/// Non-exhaustive: match with a wildcard arm — new variants are additive,
32/// not breaking.
33#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub enum PlaybackEvent {
36 /// A file finished loading and playback is starting.
37 Loaded,
38 /// Playback ended without error. Errored ends arrive as
39 /// [`Failed`](Self::Failed) instead.
40 Ended {
41 /// Why it ended — playlist logic usually advances on
42 /// [`EndReason::Eof`] and holds on [`EndReason::Stop`].
43 reason: EndReason,
44 },
45 /// Playback (re)started after a seek completed or a file began
46 /// playing: the displayed frame matches `time-pos` again. Snap
47 /// scrubber/position UI here, not while a seek is still in flight.
48 PlaybackRestart,
49 /// An observed property changed ([`Engine::observe`]); also fires
50 /// once with the current value right after observation starts.
51 PropertyChanged {
52 /// Which observation this notification belongs to — matches the
53 /// [`ObserveId`] returned by [`Engine::observe`], so two
54 /// observations of the same property stay distinguishable.
55 id: ObserveId,
56 /// The mpv property name, as passed to [`Engine::observe`].
57 name: String,
58 /// The new value, in the [`PropertyFormat`] the observation chose.
59 value: PropertyValue,
60 },
61 /// The mpv core is shutting down — e.g. a `quit` issued through
62 /// [`Engine::command`], or an input binding if input was enabled.
63 /// No further events follow; drop the [`Engine`] soon. (A file that
64 /// was playing also gets an [`Ended`](Self::Ended) with
65 /// [`EndReason::Quit`] first.)
66 Shutdown,
67 /// mpv aborted playback — unreadable, corrupt, empty, or an
68 /// unrecognized/unsupported format.
69 Failed {
70 /// Raw `client.h` `mpv_error` code (always negative). Match on
71 /// this to map failures to your own user-facing error copy.
72 code: i32,
73 /// Diagnostic text (mpv's `mpv_error_string` plus the code), not
74 /// user-facing copy.
75 message: String,
76 },
77}
78
79/// Why playback ended (mpv's `mpv_end_file_reason`).
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum EndReason {
82 /// Natural end of file.
83 Eof,
84 /// `stop` command or equivalent — user intent, don't auto-advance.
85 Stop,
86 /// The player is quitting.
87 Quit,
88 /// The entry redirected to another (e.g. a playlist file).
89 Redirect,
90 /// A reason this crate doesn't recognize; carries mpv's raw code.
91 Other(i32),
92}
93
94fn end_reason(reason: EndFileReason) -> EndReason {
95 match reason {
96 EndFileReason::Eof => EndReason::Eof,
97 EndFileReason::Stop => EndReason::Stop,
98 EndFileReason::Quit => EndReason::Quit,
99 EndFileReason::Redirect => EndReason::Redirect,
100 EndFileReason::Unknown(code) => EndReason::Other(code),
101 // `Error` is routed to `Failed` before this runs, and a future
102 // named reason (the enum is non_exhaustive) carries no raw code
103 // to forward — mpv's reason codes are non-negative, so -1 is
104 // recognizably out-of-band until this crate names the variant.
105 _ => EndReason::Other(-1),
106 }
107}
108
109/// Owned property value delivered by
110/// [`PropertyChanged`](PlaybackEvent::PropertyChanged).
111#[derive(Debug, Clone, PartialEq)]
112#[non_exhaustive]
113pub enum PropertyValue {
114 /// An mpv flag (`MPV_FORMAT_FLAG`).
115 Flag(bool),
116 /// A 64-bit integer (`MPV_FORMAT_INT64`).
117 Int(i64),
118 /// A double (`MPV_FORMAT_DOUBLE`).
119 Double(f64),
120 /// A string (`MPV_FORMAT_STRING`).
121 Str(String),
122}
123
124/// Map an event's decoded property payload onto this crate's owned value.
125/// `None` when the property is unavailable or the format is one this crate
126/// doesn't deliver (e.g. `Node`) — those notifications are skipped, not
127/// surfaced as bogus values.
128fn property_value(data: PropertyData) -> Option<PropertyValue> {
129 match data {
130 PropertyData::Flag(v) => Some(PropertyValue::Flag(v)),
131 PropertyData::Int64(v) => Some(PropertyValue::Int(v)),
132 PropertyData::Double(v) => Some(PropertyValue::Double(v)),
133 PropertyData::String(s) | PropertyData::OsdString(s) => Some(PropertyValue::Str(s)),
134 _ => None,
135 }
136}
137
138impl From<bool> for PropertyValue {
139 fn from(v: bool) -> Self {
140 Self::Flag(v)
141 }
142}
143impl From<i64> for PropertyValue {
144 fn from(v: i64) -> Self {
145 Self::Int(v)
146 }
147}
148impl From<i32> for PropertyValue {
149 fn from(v: i32) -> Self {
150 Self::Int(v.into())
151 }
152}
153impl From<u32> for PropertyValue {
154 fn from(v: u32) -> Self {
155 Self::Int(v.into())
156 }
157}
158impl From<f64> for PropertyValue {
159 fn from(v: f64) -> Self {
160 Self::Double(v)
161 }
162}
163impl From<&str> for PropertyValue {
164 fn from(v: &str) -> Self {
165 Self::Str(v.to_owned())
166 }
167}
168impl From<String> for PropertyValue {
169 fn from(v: String) -> Self {
170 Self::Str(v)
171 }
172}
173
174mod sealed {
175 use crate::error::Result;
176
177 pub trait PropertyGetImpl: Sized {
178 fn get_property(mpv: &rsmpv::Mpv, name: &str) -> Result<Self>;
179 }
180
181 macro_rules! impl_property_get {
182 ($($t:ty),*) => {$(
183 impl PropertyGetImpl for $t {
184 fn get_property(mpv: &rsmpv::Mpv, name: &str) -> Result<Self> {
185 Ok(mpv.get_property(name)?)
186 }
187 }
188 )*};
189 }
190 impl_property_get!(bool, i64, f64, String);
191}
192
193/// Types a property can be read as: `bool`, `i64`, `f64`, `String`.
194/// Sealed to the formats mpv's property API speaks — the binding's own
195/// conversion traits are deliberately not part of this crate's API, so
196/// a binding major bump can't be a breaking change here.
197pub trait PropertyGet: sealed::PropertyGetImpl {}
198impl PropertyGet for bool {}
199impl PropertyGet for i64 {}
200impl PropertyGet for f64 {}
201impl PropertyGet for String {}
202
203/// Wire format for [`Engine::observe`] — picks which [`PropertyValue`]
204/// variant change notifications carry (mpv coerces where it can).
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum PropertyFormat {
207 /// Deliver as [`PropertyValue::Flag`].
208 Flag,
209 /// Deliver as [`PropertyValue::Int`].
210 Int,
211 /// Deliver as [`PropertyValue::Double`].
212 Double,
213 /// Deliver as [`PropertyValue::Str`].
214 Str,
215}
216
217/// The shell's render-update callback as held in the engine's relay
218/// slot (see [`Engine::render_update_cb`]).
219type UpdateCallback = Arc<dyn Fn() + Send + Sync>;
220
221/// Handle returned by [`Engine::observe`]: tags that observation's
222/// [`PropertyChanged`](PlaybackEvent::PropertyChanged) events and cancels
223/// it via [`Engine::unobserve`].
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub struct ObserveId(u64);
226
227/// Configures and creates an [`Engine`]. Properties set here are applied
228/// before `mpv_initialize`, which some options require.
229pub struct EngineBuilder {
230 props: Vec<(String, String)>,
231}
232
233impl EngineBuilder {
234 /// Set an mpv property/option before initialization.
235 pub fn property(mut self, name: &str, value: &str) -> Self {
236 self.props.push((name.into(), value.into()));
237 self
238 }
239
240 /// Create the engine: forces `LC_NUMERIC=C`, applies the queued
241 /// properties, and runs `mpv_initialize`.
242 pub fn build(self) -> Result<Engine> {
243 ensure_c_numeric_locale();
244 let mut builder = Mpv::builder()?;
245 for (name, value) in &self.props {
246 builder = builder.set_property(name, value.as_str())?;
247 }
248 let mpv = Arc::new(builder.build()?);
249 Ok(Engine {
250 render: Mutex::new(None),
251 attach: Mutex::new(()),
252 pump: Mutex::new(()),
253 next_observe_id: AtomicU64::new(1),
254 pending_load: Mutex::new(None),
255 deferred_events: Mutex::new(Vec::new()),
256 render_update_cb: Arc::new(Mutex::new(Arc::new(|| {}) as UpdateCallback)),
257 mpv,
258 })
259 }
260}
261
262/// One embedded mpv player core.
263///
264/// Toolkit-agnostic by construction: no main-loop integration, no widget,
265/// no GL context of its own. A shell attaches a render target with
266/// [`attach_gl_render`](Engine::attach_gl_render), forwards the update
267/// callback to its own main loop, and drains
268/// [`pump_events`](Engine::pump_events) on whatever cadence suits it.
269pub struct Engine {
270 /// Live render context (either backend), if any. Freed-before-
271 /// terminate ordering is structural: the context co-owns the core
272 /// through its own `Arc<Mpv>`, so the core cannot terminate under a
273 /// live context regardless of field order.
274 render: Mutex<Option<RenderBackend>>,
275 /// Serializes attach calls: concurrent `mpv_render_context_create`
276 /// on one handle violates render.h's threading rules, and slot
277 /// re-checks alone can't prevent the double-create. Deliberately not
278 /// the `render` lock — the synchronous `on_update` during create
279 /// must stay free to touch render methods.
280 attach: Mutex<()>,
281 /// Keeps each [`pump_events`](Engine::pump_events) drain atomic.
282 /// rsmpv's `poll_event` is safe to call concurrently, but concurrent
283 /// pollers *split* the stream (each event goes to exactly one
284 /// caller) — two racing pumps would tear ordered sequences like
285 /// `Loaded` → `Ended` across their result batches.
286 pump: Mutex<()>,
287 /// Userdata ids handed to `mpv_observe_property`; each observation
288 /// gets a fresh one so [`unobserve`](Engine::unobserve) is precise.
289 next_observe_id: AtomicU64,
290 /// Source queued by [`load_when_ready`](Engine::load_when_ready)
291 /// until a render context attaches; the attach methods take it and
292 /// issue the `loadfile`. Cleared by any transport call that decides
293 /// what plays — `load`/`load_paused`/`stop`, or their spellings
294 /// through [`command`](Engine::command) — the newest one wins.
295 ///
296 /// Doubles as the transport-ordering lock: every such call mutates
297 /// the slot and issues its mpv command *under this guard*, so
298 /// "newest wins" is real under concurrency — a drain can't interleave
299 /// with a `stop` between taking the slot and the `loadfile` landing.
300 /// Leaf lock: nothing acquired while it is held (mpv commands take no
301 /// engine locks; wakeup callbacks are documented to call no engine
302 /// methods).
303 pending_load: Mutex<Option<String>>,
304 /// Engine-synthesized events prepended by
305 /// [`pump_events`](Engine::pump_events) — currently only a `Failed`
306 /// when a deferred load errors at attach time, so the attach methods'
307 /// `Err` can keep meaning "no context was attached".
308 deferred_events: Mutex<Vec<PlaybackEvent>>,
309 /// The live render-update callback, behind the relay closure that is
310 /// what actually gets registered with rsmpv at attach.
311 /// [`set_render_update_callback`](Engine::set_render_update_callback)
312 /// swaps this slot instead of re-registering through FFI, so the
313 /// swap holds no engine lock across a callback invocation — the
314 /// relay clones the `Arc` out under a short lock and invokes with
315 /// the lock released. All mutation goes through
316 /// [`set_update_slot`](Engine::set_update_slot), which drops the
317 /// displaced closure outside the lock.
318 render_update_cb: Arc<Mutex<UpdateCallback>>,
319 /// Shared with any live render context, which holds its own clone.
320 mpv: Arc<Mpv>,
321}
322
323// `Engine: Send + Sync` is auto-derived: rsmpv marks `Mpv` Send + Sync
324// (libmpv is thread-safe per client.h; the one caveat, single-waiter
325// `mpv_wait_event`, is upheld inside rsmpv's `poll_event`), the render
326// contexts are `Send` behind a `Sync` mutex, and the remaining fields
327// are sync primitives. Do not add manual `unsafe impl`s — they'd
328// silence the compiler if a future field is genuinely `!Send`.
329
330impl Engine {
331 /// Neutral builder with no properties preset — for consumers whose
332 /// configuration doesn't start from [`video`](Self::video) or
333 /// [`headless`](Self::headless). (The presets can also be overridden:
334 /// properties apply in call order.)
335 pub fn builder() -> EngineBuilder {
336 EngineBuilder { props: vec![] }
337 }
338
339 /// Builder preset for video playback via the libmpv render API.
340 /// Frames appear only after [`attach_gl_render`](Self::attach_gl_render);
341 /// see that method's note on load ordering.
342 pub fn video() -> EngineBuilder {
343 Self::builder()
344 .property("vo", "libmpv")
345 .property("hwdec", "auto-safe")
346 .property("keep-open", "always")
347 }
348
349 /// Builder preset for audio-only / headless use (`vo=null`): playback
350 /// starts as soon as [`load`](Self::load) runs, no render target needed.
351 /// Also what the test suite uses — no display required.
352 pub fn headless() -> EngineBuilder {
353 Self::builder()
354 .property("vo", "null")
355 .property("keep-open", "always")
356 }
357
358 /// Load a file path or URL and start playback.
359 ///
360 /// For video engines, prefer [`load_when_ready`](Self::load_when_ready)
361 /// until the shell's surface is mapped: `loadfile` before a render
362 /// context exists fails VO init and **drops the video track** (see
363 /// `load_when_ready`'s docs for the full failure). Loading paused
364 /// doesn't dodge it — the load itself is what fails — which is why
365 /// the deferred variant exists and why
366 /// [`load_paused`](Self::load_paused) is no pre-attach alternative.
367 pub fn load(&self, source: &str) -> Result<()> {
368 // A plain load supersedes any pending deferred load; the guard is
369 // held across the command so the two are one transport step.
370 let mut pending = self.pending_load.lock();
371 *pending = None;
372 self.loadfile(source)
373 }
374
375 /// Issue the raw `loadfile` command, touching no engine state. The
376 /// callers own the [`pending_load`](Self::pending_load) transport
377 /// step around this.
378 fn loadfile(&self, source: &str) -> Result<()> {
379 // rsmpv passes args as an array (`mpv_command`), so paths with
380 // spaces/quotes need no escaping here. Do not "simplify" this
381 // into a formatted command string — that reintroduces the quoting
382 // bug this crate's regression test pins.
383 self.mpv.command(&["loadfile", source])?;
384 Ok(())
385 }
386
387 /// [`load`](Self::load), but paused: pause is set *before* `loadfile`
388 /// so demuxing/audio don't start before the shell is ready. Call
389 /// [`set_paused`](Self::set_paused)`(false)` on your window-ready
390 /// signal. (Setting `pause` at init time instead has a tendency to
391 /// hang — this runtime-property ordering is the reliable variant.)
392 pub fn load_paused(&self, source: &str) -> Result<()> {
393 self.set_paused(true)?;
394 self.load(source)
395 }
396
397 /// [`load`](Self::load), deferred until frames have somewhere to go:
398 /// on a render-API engine (`vo=libmpv`, [`Engine::video`]) with no
399 /// context attached yet, the source is queued and the attach call
400 /// ([`attach_gl_render`](Self::attach_gl_render) /
401 /// [`attach_sw_render`](Self::attach_sw_render)) issues the
402 /// `loadfile` — the ordering a video shell wants, without
403 /// hand-carrying a pending-source slot between its load path and its
404 /// realize handler. On an engine that is already attached — or whose
405 /// `vo` never uses the render API ([`headless`](Self::headless), a
406 /// windowed vo), so no attach is coming — this is plain
407 /// [`load`](Self::load).
408 ///
409 /// The whole `loadfile` is deferred, not just an unpause, because a
410 /// load before the render context exists doesn't merely start
411 /// blind: mpv fails to initialize the video output and **drops the
412 /// video track** — a video-only file dies with
413 /// `MPV_ERROR_NOTHING_TO_PLAY` (-16) even when loaded paused, and a
414 /// file with audio plays sound over a permanently black surface.
415 ///
416 /// The queued source is a *pending intent*: a later
417 /// [`load`](Self::load), [`load_paused`](Self::load_paused), or
418 /// [`stop`](Self::stop) before the attach supersedes it (newest
419 /// transport call wins — including the same commands issued through
420 /// [`command`](Self::command)), and a second `load_when_ready`
421 /// replaces it. Pause
422 /// state needs no special casing — the `pause` property persists
423 /// across `loadfile`, so a consumer that pauses before the attach
424 /// gets the deferred file loaded paused, exactly as if it had been
425 /// playing.
426 ///
427 /// The defer-or-load decision reads the **current** `vo` property,
428 /// so it tracks runtime `vo` changes (via
429 /// [`set_property`](Self::set_property)) and values picked up from a
430 /// config file — not just what the builder set. The same rule covers
431 /// the window after a [`detach_render`](Self::detach_render): with
432 /// `vo` still on the render API, sources queue again awaiting a
433 /// re-attach — a shell going render-less for good should switch `vo`
434 /// (e.g. to `null`) so loads run immediately. A deferred `loadfile`
435 /// that fails at attach time surfaces as
436 /// [`PlaybackEvent::Failed`] on the next
437 /// [`pump_events`](Self::pump_events), never as an `Err` from the
438 /// attach call.
439 pub fn load_when_ready(&self, source: &str) -> Result<()> {
440 if !self.expects_render() || self.has_render() {
441 return self.load(source);
442 }
443 *self.pending_load.lock() = Some(source.to_owned());
444 // An attach can slip in between `has_render()` above and the
445 // queueing; it would find the slot empty and never load. Re-check
446 // and drain — the `take` inside makes exactly one loader win if
447 // the attach also saw the source.
448 if self.has_render() {
449 return self.load_pending();
450 }
451 Ok(())
452 }
453
454 /// Whether frames route through the render API right now: the
455 /// current `vo` property includes `libmpv` (it accepts a
456 /// comma-separated fallback chain). Read live rather than snapshotted
457 /// at build — `vo` is runtime-settable through this crate's own
458 /// [`set_property`](Self::set_property), and a config file can set it
459 /// behind the builder's back. On a read failure, err toward a plain
460 /// load: the `loadfile` then surfaces the real error instead of the
461 /// source silently parking in the queue.
462 fn expects_render(&self) -> bool {
463 self.get_property::<String>("vo")
464 .is_ok_and(|vo| vo.split(',').any(|part| part.trim() == "libmpv"))
465 }
466
467 /// Escape hatch: any mpv command, args passed as an array (no quoting
468 /// needed).
469 ///
470 /// Commands that decide what plays next — `loadfile`, `loadlist`,
471 /// `stop`, `quit`, `quit-watch-later` — also discard a load queued by
472 /// [`load_when_ready`](Self::load_when_ready), same as the typed
473 /// transport methods: this is the only way to issue `loadfile` with
474 /// flags, and a superseded source must not resurface at attach time.
475 pub fn command(&self, name: &str, args: &[&str]) -> Result<()> {
476 let mut argv = Vec::with_capacity(args.len() + 1);
477 argv.push(name);
478 argv.extend_from_slice(args);
479 let supersedes_pending = matches!(
480 name,
481 "loadfile" | "loadlist" | "stop" | "quit" | "quit-watch-later"
482 );
483 // Transport commands run under the pending_load guard (clear +
484 // command as one step); everything else goes straight through.
485 let _transport = supersedes_pending.then(|| {
486 let mut pending = self.pending_load.lock();
487 *pending = None;
488 pending
489 });
490 self.mpv.command(&argv)?;
491 Ok(())
492 }
493
494 /// Set an mpv property. Accepts `bool` / `i64` / `f64` / `&str` /
495 /// `String` (anything [`Into<PropertyValue>`]).
496 pub fn set_property(&self, name: &str, value: impl Into<PropertyValue>) -> Result<()> {
497 match value.into() {
498 PropertyValue::Flag(v) => self.mpv.set_property(name, v)?,
499 PropertyValue::Int(v) => self.mpv.set_property(name, v)?,
500 PropertyValue::Double(v) => self.mpv.set_property(name, v)?,
501 PropertyValue::Str(v) => self.mpv.set_property(name, v)?,
502 }
503 Ok(())
504 }
505
506 /// Read an mpv property as `bool`, `i64`, `f64`, or `String`
507 /// ([`PropertyGet`] is sealed to those).
508 pub fn get_property<T: PropertyGet>(&self, name: &str) -> Result<T> {
509 T::get_property(&self.mpv, name)
510 }
511
512 /// Pause (`true`) or resume (`false`) playback.
513 pub fn set_paused(&self, paused: bool) -> Result<()> {
514 self.set_property("pause", paused)
515 }
516
517 /// Best-effort pause-state query; `false` when nothing is loaded yet
518 /// — use [`is_idle`](Self::is_idle) to tell "playing" apart from
519 /// "nothing to play".
520 pub fn is_paused(&self) -> bool {
521 self.get_property("pause").unwrap_or(false)
522 }
523
524 /// True when no file is loaded (`idle-active`) — distinguishes
525 /// "nothing to pause" from [`is_paused`](Self::is_paused) being
526 /// `false`.
527 pub fn is_idle(&self) -> bool {
528 self.get_property("idle-active").unwrap_or(true)
529 }
530
531 /// Stop playback and unload the current file. Surfaces as
532 /// [`PlaybackEvent::Ended`] with [`EndReason::Stop`]. Also discards a
533 /// load queued by [`load_when_ready`](Self::load_when_ready) — there
534 /// is nothing left to play.
535 pub fn stop(&self) -> Result<()> {
536 // `command` clears the pending deferred load (transport command).
537 self.command("stop", &[])
538 }
539
540 /// Volume in percent: 0–100 is normal range, above 100 amplifies (up
541 /// to mpv's `volume-max`).
542 pub fn set_volume(&self, percent: f64) -> Result<()> {
543 self.set_property("volume", percent)
544 }
545
546 /// Best-effort volume query in percent.
547 pub fn volume(&self) -> Option<f64> {
548 self.get_property("volume").ok()
549 }
550
551 /// Mute (`true`) or unmute (`false`) audio.
552 pub fn set_muted(&self, muted: bool) -> Result<()> {
553 self.set_property("mute", muted)
554 }
555
556 /// Best-effort mute-state query; `false` when nothing is loaded yet.
557 pub fn is_muted(&self) -> bool {
558 self.get_property("mute").unwrap_or(false)
559 }
560
561 /// Playback speed multiplier (`1.0` = normal).
562 pub fn set_speed(&self, speed: f64) -> Result<()> {
563 self.set_property("speed", speed)
564 }
565
566 /// Best-effort speed query.
567 pub fn speed(&self) -> Option<f64> {
568 self.get_property("speed").ok()
569 }
570
571 /// Current playback position in seconds, if a file is loaded.
572 pub fn position(&self) -> Option<f64> {
573 self.get_property("time-pos").ok()
574 }
575
576 /// Total duration in seconds. `None` while mpv is still parsing or for
577 /// unknown-duration streams.
578 pub fn duration(&self) -> Option<f64> {
579 self.get_property("duration").ok()
580 }
581
582 /// Seek to an absolute position in seconds.
583 pub fn seek_absolute(&self, secs: f64) -> Result<()> {
584 self.command("seek", &[&format!("{secs:.3}"), "absolute"])
585 }
586
587 /// Seek by a delta in seconds (negative seeks backward).
588 pub fn seek_relative(&self, secs: f64) -> Result<()> {
589 self.command("seek", &[&format!("{secs:.3}"), "relative"])
590 }
591
592 /// Observe a property for changes: matching
593 /// [`PlaybackEvent::PropertyChanged`] events arrive via
594 /// [`pump_events`](Self::pump_events), starting with one carrying the
595 /// current value (handy for initializing UI state). `format` picks
596 /// the delivered [`PropertyValue`] variant; mpv coerces where it can.
597 ///
598 /// Typical player set: `pause` (Flag), `time-pos`/`duration`
599 /// (Double), `paused-for-cache` (Flag), `dwidth`/`dheight` (Int).
600 pub fn observe(&self, name: &str, format: PropertyFormat) -> Result<ObserveId> {
601 let fmt = match format {
602 PropertyFormat::Flag => Format::Flag,
603 PropertyFormat::Int => Format::Int64,
604 PropertyFormat::Double => Format::Double,
605 PropertyFormat::Str => Format::String,
606 };
607 let id = self.next_observe_id.fetch_add(1, Ordering::Relaxed);
608 self.mpv.observe_property(id, name, fmt)?;
609 Ok(ObserveId(id))
610 }
611
612 /// Cancel one observation made with [`observe`](Self::observe).
613 pub fn unobserve(&self, id: ObserveId) -> Result<()> {
614 self.mpv.unobserve_property(id.0)?;
615 Ok(())
616 }
617
618 /// Register a callback fired whenever mpv queues new events — the
619 /// push alternative to polling [`pump_events`](Self::pump_events) on
620 /// a timer, and the only timely signal when no frames are flowing
621 /// (audio-only playback, a load failure while paused).
622 ///
623 /// The callback also fires **once synchronously during this call**
624 /// (so the construct → share → register ordering documented on the
625 /// attach methods applies here too), and mpv may additionally fire
626 /// it spuriously. Treat a wakeup as "check the queue", never "an
627 /// event arrived".
628 ///
629 /// Fires on arbitrary mpv-internal threads — possibly several at
630 /// once (hence `Sync`), possibly re-entrantly with other engine
631 /// calls: do no work and call no engine methods inside — signal your
632 /// main loop and pump from there (the same bridging pattern as the
633 /// render-update callback). Replaces any previously registered
634 /// wakeup callback.
635 pub fn set_wakeup_callback(&self, on_wakeup: impl Fn() + Send + Sync + 'static) {
636 // rsmpv owns the closure lifecycle: a replaced callback is freed
637 // once its last in-flight invocation finishes (possibly on an
638 // mpv-internal thread), and everything is unhooked safely during
639 // handle teardown. mpv only invokes the latest registration.
640 self.mpv.set_wakeup_callback(on_wakeup);
641 }
642
643 /// Drain pending mpv events into typed [`PlaybackEvent`]s. Call on a
644 /// timer or after the update callback; never blocks.
645 ///
646 /// Built on rsmpv's non-blocking `poll_event` (`&self`; internally
647 /// serialized against libmpv's one-waiter-per-handle rule). The
648 /// engine adds the `pump` lock on top so each drain is atomic —
649 /// concurrent pollers would otherwise split the stream, tearing
650 /// ordered sequences across callers' batches.
651 pub fn pump_events(&self) -> Vec<PlaybackEvent> {
652 let _guard = self.pump.lock();
653 // Engine-synthesized events first (a deferred load that failed at
654 // attach time) — they predate whatever mpv has queued now.
655 let mut out = std::mem::take(&mut *self.deferred_events.lock());
656 while let Some(ev) = self.mpv.poll_event() {
657 match ev {
658 Event::Shutdown => out.push(PlaybackEvent::Shutdown),
659 Event::FileLoaded => out.push(PlaybackEvent::Loaded),
660 // An errored end-of-file (bad format, load failure,
661 // missing/corrupt/empty data) surfaces as a typed
662 // `Failed` carrying mpv's error code — never as a quiet
663 // `Ended`.
664 Event::EndFile {
665 reason: EndFileReason::Error,
666 error,
667 ..
668 } => {
669 // Event errors always map onto a raw libmpv code
670 // (rsmpv decodes them from one); `error` itself is
671 // only absent if mpv violates its own contract of
672 // setting a code on errored ends. Generic backstops
673 // both impossibilities.
674 let code = error
675 .and_then(|e| e.raw_code())
676 .unwrap_or(sys::MPV_ERROR_GENERIC);
677 out.push(PlaybackEvent::Failed {
678 code,
679 message: describe_code(code),
680 });
681 }
682 Event::EndFile { reason, .. } => out.push(PlaybackEvent::Ended {
683 reason: end_reason(reason),
684 }),
685 Event::PlaybackRestart => out.push(PlaybackEvent::PlaybackRestart),
686 Event::PropertyChange {
687 userdata,
688 name,
689 data,
690 } => {
691 // A `None` here means the property became unavailable
692 // (or arrived in a format this crate doesn't deliver)
693 // — skipped rather than delivered as a bogus value,
694 // and the drain keeps going.
695 if let Some(value) = property_value(data) {
696 out.push(PlaybackEvent::PropertyChanged {
697 id: ObserveId(userdata),
698 name,
699 value,
700 });
701 }
702 }
703 _ => {}
704 }
705 }
706 out
707 }
708
709 /// Create the OpenGL render context. Call with the target GL context
710 /// current (e.g. GTK: in the GLArea `realize` handler).
711 ///
712 /// `get_proc_address` resolves GL symbols (on Linux, EGL 1.5's
713 /// `eglGetProcAddress` covers everything mpv asks for — beware
714 /// libepoxy on glvnd builds, which doesn't export core GL symbols as
715 /// plain `dlsym`-able functions and makes mpv report
716 /// `MPV_ERROR_UNSUPPORTED`). `on_update` fires on **mpv's render
717 /// thread** — and once synchronously during this call — to signal "a
718 /// new frame wants drawing"; forward it to your main loop and call
719 /// [`render_gl`](Self::render_gl) from your draw handler.
720 ///
721 /// The synchronous first call arrives *before* the context is stored:
722 /// a [`render_gl`](Self::render_gl) from inside it no-ops (harmlessly
723 /// — mpv re-signals). It runs on the caller's thread but outside the
724 /// engine's render lock, so calling back into render methods cannot
725 /// deadlock.
726 ///
727 /// `on_update` typically captures a `Weak` handle to your player
728 /// state — construct the `Engine`, wrap it in your `Arc`/shared
729 /// structure, *then* attach with the weak-capturing closure, then
730 /// load.
731 ///
732 /// A successful attach also issues any load queued by
733 /// [`load_when_ready`](Self::load_when_ready). `Err` still means "no
734 /// context was attached" — a deferred `loadfile` that fails here
735 /// leaves the context in place and surfaces as
736 /// [`PlaybackEvent::Failed`] on the next
737 /// [`pump_events`](Self::pump_events) (the wakeup callback fires).
738 ///
739 /// `options` fixes the shell's render-loop discipline at attach:
740 /// frame pacing ([`GlRenderOptions::block_for_target_time`]) and
741 /// mpv's advanced control ([`GlRenderOptions::advanced_control`],
742 /// which obligates [`render_update`](Self::render_update) after every
743 /// update callback). [`GlRenderOptions::default`] is mpv's stock
744 /// behavior.
745 ///
746 /// # Safety
747 /// GL-context currency is a dynamic, per-call rule the type system
748 /// cannot capture (rsmpv's OpenGL constructor is `unsafe` for the
749 /// same reason, and this crate forwards the obligation rather than
750 /// hiding it): the target GL context must be current on the calling
751 /// thread now, on every later [`render_gl`](Self::render_gl) or
752 /// [`render_update`](Self::render_update), and when the context is
753 /// freed — [`detach_render`](Self::detach_render) or the engine's
754 /// drop. Violating the rule is undefined behavior.
755 pub unsafe fn attach_gl_render(
756 &self,
757 get_proc_address: ProcAddressFn,
758 options: GlRenderOptions,
759 on_update: impl Fn() + Send + Sync + 'static,
760 ) -> Result<()> {
761 let _attaching = self.attach.lock();
762 if self.render.lock().is_some() {
763 return Err(Error::AlreadyAttached);
764 }
765 // Construct with the render lock *released*: registration fires
766 // `on_update` synchronously (via the relay), and holding the
767 // render lock across that call would deadlock an `on_update` that
768 // touches render methods. The attach lock keeps a second attacher
769 // out, so the slot check above stays authoritative. (An Arc clone
770 // goes in — never the engine's own reference — so a failed create
771 // can't drop the core.)
772 self.set_update_slot(Arc::new(on_update));
773 // SAFETY: GL-currency contract forwarded to the caller (above).
774 let created = unsafe {
775 GlRender::create(
776 self.mpv.clone(),
777 get_proc_address,
778 options,
779 self.update_relay(),
780 )
781 };
782 let render = match created {
783 Ok(r) => r,
784 Err(e) => {
785 // Failed attach: don't pin the shell closure's captures
786 // in a slot nothing will ever fire.
787 self.set_update_slot(Arc::new(|| {}));
788 return Err(e);
789 }
790 };
791 *self.render.lock() = Some(RenderBackend::Gl(render));
792 tracing::debug!("mpv GL render context attached");
793 self.drain_pending_load();
794 Ok(())
795 }
796
797 /// Create the software render context: frames arrive as RGBA bytes
798 /// via [`render_sw`](Self::render_sw), no GL anywhere — the
799 /// backend for shells that upload pixels themselves (or hand them to
800 /// a non-GL compositor). Unlike the GL backend there are no
801 /// context-current requirements, for rendering or teardown.
802 ///
803 /// `on_update` has the same contract as in
804 /// [`attach_gl_render`](Self::attach_gl_render): fires on mpv's
805 /// render thread plus once synchronously (outside the render lock,
806 /// before the context is stored), and typically captures a `Weak`
807 /// handle — construct, share, attach, then load.
808 ///
809 /// As with [`attach_gl_render`](Self::attach_gl_render): a successful
810 /// attach issues any [`load_when_ready`](Self::load_when_ready)
811 /// queue, `Err` still means "no context was attached", and a deferred
812 /// `loadfile` failing here surfaces as [`PlaybackEvent::Failed`] on
813 /// the next [`pump_events`](Self::pump_events) instead.
814 pub fn attach_sw_render(&self, on_update: impl Fn() + Send + Sync + 'static) -> Result<()> {
815 // Same locking shape as `attach_gl_render`, for the same reasons.
816 let _attaching = self.attach.lock();
817 if self.render.lock().is_some() {
818 return Err(Error::AlreadyAttached);
819 }
820 self.set_update_slot(Arc::new(on_update));
821 let render = match SwRender::create(self.mpv.clone(), self.update_relay()) {
822 Ok(r) => r,
823 Err(e) => {
824 self.set_update_slot(Arc::new(|| {}));
825 return Err(e);
826 }
827 };
828 *self.render.lock() = Some(RenderBackend::Sw(render));
829 tracing::debug!("mpv software render context attached");
830 self.drain_pending_load();
831 Ok(())
832 }
833
834 /// Issue the `loadfile` a [`load_when_ready`](Self::load_when_ready)
835 /// queued, now that a render context is stored. The guard is held
836 /// across the `loadfile` — take-then-load must be one transport step,
837 /// or a concurrent `stop`/`load` landing in between would be
838 /// overridden by the older, superseded source.
839 fn load_pending(&self) -> Result<()> {
840 let mut pending = self.pending_load.lock();
841 match pending.take() {
842 Some(source) => self.loadfile(&source),
843 None => Ok(()),
844 }
845 }
846
847 /// [`load_pending`](Self::load_pending) for the attach paths, where
848 /// an `Err` must keep meaning "no context was attached": the freshly
849 /// stored context stays either way, so a deferred-load failure is
850 /// rerouted onto the event stream as [`PlaybackEvent::Failed`] — the
851 /// channel an asynchronous load failure was headed for anyway — and
852 /// the wakeup callback is tickled so push-driven shells pump for it.
853 fn drain_pending_load(&self) {
854 let Err(err) = self.load_pending() else {
855 return;
856 };
857 tracing::warn!("deferred load failed at attach: {err}");
858 let code = match &err {
859 Error::Mpv(e) => e.raw_code().unwrap_or(sys::MPV_ERROR_GENERIC),
860 _ => sys::MPV_ERROR_GENERIC,
861 };
862 self.deferred_events.lock().push(PlaybackEvent::Failed {
863 code,
864 message: describe_code(code),
865 });
866 self.mpv.wakeup();
867 }
868
869 /// Drop the render context *now*. For the OpenGL backend, call with
870 /// the GL context still current (GTK: from the `unrealize` handler):
871 /// freeing without the right context current leaks mpv's GL objects
872 /// into whatever context is current — in GTK that painted artifacts
873 /// over the whole window. The software backend has no such
874 /// requirement; detach from any thread.
875 ///
876 /// After a detach, [`load_when_ready`](Self::load_when_ready) defers
877 /// again — `vo` still names the render API, so sources queue awaiting
878 /// a re-attach. A shell detaching *for good* (say, dropping to
879 /// audio-only) should also switch `vo` (e.g.
880 /// [`set_property`](Self::set_property)`("vo", "null")`); the
881 /// defer-or-load decision reads the live `vo`, so loads then run
882 /// immediately instead of parking.
883 pub fn detach_render(&self) {
884 if self.render.lock().take().is_some() {
885 // Release the shell's callback with the context: nothing can
886 // fire it anymore, and keeping it would pin its captures
887 // (typically shell window state) until the next attach.
888 self.set_update_slot(Arc::new(|| {}));
889 tracing::debug!("mpv render context detached");
890 }
891 }
892
893 /// Whether a render context (of either backend) is currently
894 /// attached. Delegates to [`attached_render`](Self::attached_render)
895 /// — one read of the slot, so the two can never disagree.
896 pub fn has_render(&self) -> bool {
897 self.attached_render().is_some()
898 }
899
900 /// Which render backend is attached, if any — the "which one"
901 /// companion to [`has_render`](Self::has_render), for shells that
902 /// route between per-backend code paths (say, GPU texture sampling
903 /// vs. RGBA upload) without having to track the attach outcome in
904 /// state of their own.
905 pub fn attached_render(&self) -> Option<RenderKind> {
906 self.render.lock().as_ref().map(RenderBackend::kind)
907 }
908
909 /// Store `cb` as the live render-update callback, dropping the
910 /// displaced closure *outside* the slot lock — its captures may carry
911 /// a `Drop` that calls back into the engine, which must not run under
912 /// any engine lock.
913 fn set_update_slot(&self, cb: UpdateCallback) {
914 let old = std::mem::replace(&mut *self.render_update_cb.lock(), cb);
915 drop(old);
916 }
917
918 /// The closure actually registered with rsmpv at attach: reads the
919 /// engine's callback slot on every fire, so
920 /// [`set_render_update_callback`](Self::set_render_update_callback)
921 /// can swap the target without re-registering through FFI. The `Arc`
922 /// is cloned out under a short lock and invoked with the lock
923 /// released — no engine lock is ever held across a callback
924 /// invocation.
925 fn update_relay(&self) -> impl Fn() + Send + Sync + 'static {
926 let slot = Arc::clone(&self.render_update_cb);
927 move || {
928 let cb = Arc::clone(&*slot.lock());
929 cb();
930 }
931 }
932
933 /// Replace the render-update callback registered at attach — the same
934 /// post-registration replaceability
935 /// [`set_wakeup_callback`](Self::set_wakeup_callback) has, for the
936 /// render seam. For shells that can only build their real closure
937 /// after the engine is shared: attach with a placeholder, wrap the
938 /// engine in your `Arc`/shared structure, then register the
939 /// weak-capturing closure here.
940 ///
941 /// The new callback takes over the attach-time contract: it fires on
942 /// mpv's render thread — and **once synchronously on the calling
943 /// thread, from inside this very call** (registration raises an
944 /// update immediately, so a frame signaled to the old callback isn't
945 /// lost). The synchronous fire runs outside every engine lock, same
946 /// as at attach — an engine call from inside it cannot deadlock. The
947 /// standing rule still applies to the mpv-thread fires, though: do no
948 /// work and call no engine methods inside — signal your main loop and
949 /// render/pump from there.
950 ///
951 /// The replaced closure is released with no engine lock held: on this
952 /// thread during this call when no invocation is in flight, otherwise
953 /// when its last in-flight invocation finishes — possibly on an
954 /// mpv-internal thread, so captures whose `Drop` calls into libmpv
955 /// (e.g. a last `Engine`-owning handle) don't belong in an update
956 /// callback.
957 ///
958 /// The registration is tied to the attached context:
959 /// [`detach_render`](Self::detach_render) releases it, and the next
960 /// attach starts from that attach's own `on_update`.
961 ///
962 /// Errors with [`Error::NotAttached`] when no render context is
963 /// attached — a callback that could never fire is a wiring bug,
964 /// surfaced loudly rather than silently dropped.
965 pub fn set_render_update_callback(
966 &self,
967 on_update: impl Fn() + Send + Sync + 'static,
968 ) -> Result<()> {
969 if !self.has_render() {
970 return Err(Error::NotAttached);
971 }
972 let new: UpdateCallback = Arc::new(on_update);
973 self.set_update_slot(Arc::clone(&new));
974 // The synchronous registration fire, with no engine lock held.
975 new();
976 Ok(())
977 }
978
979 /// Process pending render work after an update callback fired (never
980 /// call it from inside the callback itself — that's forbidden, like
981 /// any other engine call there). Returns `true` when a new frame
982 /// should be drawn. Optional under default options; **mandatory
983 /// promptly after every update callback** when the GL backend was
984 /// attached with [`GlRenderOptions::advanced_control`]. `false` when
985 /// no backend is attached. For the GL backend, the attach contract's
986 /// GL-currency rule covers this call too.
987 pub fn render_update(&self) -> bool {
988 self.render
989 .lock()
990 .as_mut()
991 .is_some_and(RenderBackend::update)
992 }
993
994 /// Draw the current frame into `fbo` (`0` = default framebuffer) with
995 /// the GL context current. No-op before
996 /// [`attach_gl_render`](Self::attach_gl_render); errors with
997 /// [`Error::RenderBackendMismatch`] if the software backend is
998 /// attached instead. `flip_y` flips the output for flipped-origin
999 /// targets (GTK's GLArea wants `true`). Whether this call blocks
1000 /// until the frame's target display time was fixed at attach
1001 /// ([`GlRenderOptions::block_for_target_time`]; the default blocks).
1002 pub fn render_gl(&self, fbo: i32, w: i32, h: i32, flip_y: bool) -> Result<()> {
1003 match self.render.lock().as_mut() {
1004 Some(RenderBackend::Gl(r)) => r.render(fbo, w, h, flip_y),
1005 Some(_) => Err(Error::RenderBackendMismatch),
1006 None => Ok(()),
1007 }
1008 }
1009
1010 /// Render the current frame as RGBA8 into `buf` (resized to
1011 /// `w * h * 4`; alpha always opaque). Callable from any thread.
1012 /// No-op before [`attach_sw_render`](Self::attach_sw_render) —
1013 /// `buf` is left untouched; errors with
1014 /// [`Error::RenderBackendMismatch`] if the OpenGL backend is
1015 /// attached instead.
1016 pub fn render_sw(&self, w: i32, h: i32, buf: &mut Vec<u8>) -> Result<()> {
1017 match self.render.lock().as_mut() {
1018 Some(RenderBackend::Sw(r)) => r.render(w, h, buf),
1019 Some(_) => Err(Error::RenderBackendMismatch),
1020 None => Ok(()),
1021 }
1022 }
1023}
1024
1025// No manual `Drop`: the render context co-owns the core through its own
1026// `Arc<Mpv>`, so it is structurally freed before the core terminates —
1027// field order can't break that anymore. If a GL target was attached,
1028// prefer an explicit `detach_render` with the GL context current before
1029// dropping; the implicit drop can't make that guarantee. (Wakeup- and
1030// update-callback teardown is rsmpv's: closures are released safely even
1031// against in-flight invocations, so mpv can never fire into freed memory.)