Skip to main content

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