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