medias 0.4.0

TV media player on bare DRM/KMS: Vulkan UI plane over libmpv video, for local files, UPnP/DLNA servers and YouTube, controlled by HDMI-CEC remote, evdev or terminal
//! libmpv engine (milestone 4a): decode + A/V clock + control plane.
//!
//! Thin hand-written FFI to the stable libmpv client API (v2.x). We bind only
//! the ~10 functions we use, avoiding a bindgen/libclang build dependency.
//! mpv is a pure decoder/clock here; video output to a KMS plane is milestone 4b
//! (the render API is GL/SW only, so decoded frames reach a plane via the
//! compositor, not through this control plane).

use std::ffi::{CStr, CString, c_char, c_double, c_int, c_void};

mod ffi {
    use super::{c_char, c_double, c_int, c_void};

    pub enum Handle {}
    pub enum RenderCtx {}

    // mpv_format
    pub const FORMAT_DOUBLE: c_int = 5;

    // mpv_event_id
    pub const EVENT_SHUTDOWN: c_int = 1;
    pub const EVENT_END_FILE: c_int = 7;
    pub const EVENT_FILE_LOADED: c_int = 8;
    pub const EVENT_SEEK: c_int = 20;
    pub const EVENT_PLAYBACK_RESTART: c_int = 21;

    // mpv_end_file_reason (first int of mpv_event_end_file at event.data)
    pub const END_FILE_EOF: c_int = 0;
    pub const END_FILE_ERROR: c_int = 4;

    // mpv_render_param_type
    pub const RENDER_PARAM_API_TYPE: c_int = 1;
    pub const RENDER_PARAM_OPENGL_INIT_PARAMS: c_int = 2;
    pub const RENDER_PARAM_OPENGL_FBO: c_int = 3;
    pub const RENDER_PARAM_FLIP_Y: c_int = 4;
    pub const RENDER_UPDATE_FRAME: u64 = 1;
    pub const API_TYPE_OPENGL: &[u8] = b"opengl\0";

    #[repr(C)]
    pub struct Event {
        pub event_id: c_int,
        pub error: c_int,
        pub reply_userdata: u64,
        pub data: *mut c_void,
    }

    #[repr(C)]
    pub struct RenderParam {
        pub type_: c_int,
        pub data: *mut c_void,
    }

    pub type GetProcAddress = extern "C" fn(*mut c_void, *const c_char) -> *mut c_void;

    #[repr(C)]
    pub struct OpenglInitParams {
        pub get_proc_address: GetProcAddress,
        pub get_proc_address_ctx: *mut c_void,
    }

    #[repr(C)]
    pub struct OpenglFbo {
        pub fbo: c_int,
        pub w: c_int,
        pub h: c_int,
        pub internal_format: c_int,
    }

    #[link(name = "mpv")]
    unsafe extern "C" {
        pub fn mpv_create() -> *mut Handle;
        pub fn mpv_initialize(ctx: *mut Handle) -> c_int;
        pub fn mpv_terminate_destroy(ctx: *mut Handle);
        pub fn mpv_set_option_string(ctx: *mut Handle, name: *const c_char, data: *const c_char) -> c_int;
        #[allow(dead_code)] // used for pause/seek in 4b/5
        pub fn mpv_set_property_string(ctx: *mut Handle, name: *const c_char, data: *const c_char) -> c_int;
        pub fn mpv_get_property(ctx: *mut Handle, name: *const c_char, format: c_int, data: *mut c_void) -> c_int;
        pub fn mpv_get_property_string(ctx: *mut Handle, name: *const c_char) -> *mut c_char;
        pub fn mpv_free(data: *mut c_void);
        pub fn mpv_command(ctx: *mut Handle, args: *const *const c_char) -> c_int;
        pub fn mpv_wait_event(ctx: *mut Handle, timeout: c_double) -> *mut Event;
        pub fn mpv_error_string(error: c_int) -> *const c_char;
        pub fn mpv_event_name(id: c_int) -> *const c_char;

        pub fn mpv_render_context_create(res: *mut *mut RenderCtx, mpv: *mut Handle, params: *const RenderParam) -> c_int;
        pub fn mpv_render_context_render(ctx: *mut RenderCtx, params: *const RenderParam) -> c_int;
        pub fn mpv_render_context_update(ctx: *mut RenderCtx) -> u64;
        pub fn mpv_render_context_free(ctx: *mut RenderCtx);
    }
}

/// What `wait_event` surfaced this tick.
#[derive(Debug, PartialEq)]
pub enum Event {
    None,
    FileLoaded,
    Seek,
    PlaybackRestart,
    /// Playback of a file ended. `true` only for a natural end (EOF);
    /// `false` when we stopped/replaced it ourselves (reason stop/redirect).
    EndFile(bool),
    /// Loading or playback failed; the saved resume position remains valid.
    PlaybackError,
    Shutdown,
    Other(String),
}

pub struct Mpv {
    ctx: *mut ffi::Handle,
}

impl Mpv {
    /// Create an uninitialized context. Options must be set before `init`.
    pub fn new() -> Result<Mpv, String> {
        let ctx = unsafe { ffi::mpv_create() };
        if ctx.is_null() {
            return Err("mpv_create failed".into());
        }
        Ok(Mpv { ctx })
    }

    pub fn set_option(&self, name: &str, value: &str) -> Result<(), String> {
        let (n, v) = (cstr(name), cstr(value));
        check(unsafe { ffi::mpv_set_option_string(self.ctx, n.as_ptr(), v.as_ptr()) }, "set_option")
    }

    #[allow(dead_code)] // used for pause/seek in 4b/5
    pub fn set_property(&self, name: &str, value: &str) -> Result<(), String> {
        let (n, v) = (cstr(name), cstr(value));
        check(unsafe { ffi::mpv_set_property_string(self.ctx, n.as_ptr(), v.as_ptr()) }, "set_property")
    }

    pub fn init(&self) -> Result<(), String> {
        check(unsafe { ffi::mpv_initialize(self.ctx) }, "initialize")
    }

    pub fn command(&self, args: &[&str]) -> Result<(), String> {
        let owned: Vec<CString> = args.iter().map(|a| cstr(a)).collect();
        let mut ptrs: Vec<*const c_char> = owned.iter().map(|c| c.as_ptr()).collect();
        ptrs.push(std::ptr::null());
        check(unsafe { ffi::mpv_command(self.ctx, ptrs.as_ptr()) }, "command")
    }

    pub fn loadfile(&self, url: &str) -> Result<(), String> {
        self.command(&["loadfile", url])
    }

    /// loadfile with per-file options ("sid=no"), reset at the next load.
    pub fn loadfile_with(&self, url: &str, options: &str) -> Result<(), String> {
        self.command(&["loadfile", url, "replace", "-1", options])
    }

    /// Set the external audio tracks for subsequent loads (empty clears them).
    /// `change-list append` takes each URL verbatim, without list splitting.
    pub fn set_audio_files(&self, urls: &[&str]) -> Result<(), String> {
        self.command(&["change-list", "audio-files", "clr", ""])?;
        for url in urls {
            self.command(&["change-list", "audio-files", "append", url])?;
        }
        Ok(())
    }

    /// Set the external subtitle tracks for subsequent loads (empty clears them).
    pub fn set_sub_files(&self, urls: &[&str]) -> Result<(), String> {
        self.command(&["change-list", "sub-files", "clr", ""])?;
        for url in urls {
            self.command(&["change-list", "sub-files", "append", url])?;
        }
        Ok(())
    }

    /// Read a double property (e.g. "time-pos", "duration"). None if currently
    /// unavailable.
    pub fn get_f64(&self, name: &str) -> Option<f64> {
        let n = cstr(name);
        let mut v: f64 = 0.0;
        let r = unsafe {
            ffi::mpv_get_property(self.ctx, n.as_ptr(), ffi::FORMAT_DOUBLE, &mut v as *mut f64 as *mut c_void)
        };
        (r >= 0).then_some(v)
    }

    /// Read a property as a string (e.g. "aid", "sid"). None if unavailable.
    pub fn get_string(&self, name: &str) -> Option<String> {
        let n = cstr(name);
        let ptr = unsafe { ffi::mpv_get_property_string(self.ctx, n.as_ptr()) };
        if ptr.is_null() {
            return None;
        }
        let s = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
        unsafe { ffi::mpv_free(ptr as *mut c_void) };
        Some(s)
    }

    /// Block up to `timeout` seconds for the next event.
    pub fn wait_event(&self, timeout: f64) -> Event {
        let ev = unsafe { &*ffi::mpv_wait_event(self.ctx, timeout) };
        match ev.event_id {
            0 => Event::None,
            ffi::EVENT_FILE_LOADED => Event::FileLoaded,
            ffi::EVENT_SEEK => Event::Seek,
            ffi::EVENT_PLAYBACK_RESTART => Event::PlaybackRestart,
            ffi::EVENT_END_FILE => {
                // data -> mpv_event_end_file; first int is the reason.
                if ev.data.is_null() {
                    Event::PlaybackError
                } else {
                    let reason = unsafe { *(ev.data as *const c_int) };
                    if reason == ffi::END_FILE_ERROR { Event::PlaybackError }
                    else { Event::EndFile(reason == ffi::END_FILE_EOF) }
                }
            }
            ffi::EVENT_SHUTDOWN => Event::Shutdown,
            id => Event::Other(event_name(id)),
        }
    }
}

impl Mpv {
    /// Create an OpenGL render context. `get_proc` resolves GL symbols (e.g. via
    /// eglGetProcAddress). Must be freed (and is, on drop) before the Mpv.
    pub fn render_context(&self, get_proc: ffi::GetProcAddress) -> Result<RenderContext<'_>, String> {
        let mut init = ffi::OpenglInitParams {
            get_proc_address: get_proc,
            get_proc_address_ctx: std::ptr::null_mut(),
        };
        let params = [
            ffi::RenderParam {
                type_: ffi::RENDER_PARAM_API_TYPE,
                data: ffi::API_TYPE_OPENGL.as_ptr() as *mut c_void,
            },
            ffi::RenderParam {
                type_: ffi::RENDER_PARAM_OPENGL_INIT_PARAMS,
                data: &mut init as *mut _ as *mut c_void,
            },
            ffi::RenderParam { type_: 0, data: std::ptr::null_mut() },
        ];
        let mut ctx: *mut ffi::RenderCtx = std::ptr::null_mut();
        check(
            unsafe { ffi::mpv_render_context_create(&mut ctx, self.ctx, params.as_ptr()) },
            "render_context_create",
        )?;
        Ok(RenderContext { ctx, _mpv: self })
    }
}

/// OpenGL render context. Renders the current video frame into a caller-owned
/// FBO. Tied to the Mpv's lifetime so it is freed first.
pub struct RenderContext<'a> {
    ctx: *mut ffi::RenderCtx,
    _mpv: &'a Mpv,
}

impl RenderContext<'_> {
    /// True if a new frame is ready to render.
    pub fn frame_ready(&self) -> bool {
        unsafe { ffi::mpv_render_context_update(self.ctx) & ffi::RENDER_UPDATE_FRAME != 0 }
    }

    /// Render the current frame into `fbo` (GL framebuffer name, size w*h).
    pub fn render(&self, fbo: i32, w: i32, h: i32) -> Result<(), String> {
        let mut f = ffi::OpenglFbo { fbo, w, h, internal_format: 0 };
        // 0: the GL FBO is backed by a dmabuf scanned out top-down, so mpv's
        // output already matches KMS orientation; flipping would invert it.
        let mut flip: c_int = 0;
        let params = [
            ffi::RenderParam {
                type_: ffi::RENDER_PARAM_OPENGL_FBO,
                data: &mut f as *mut _ as *mut c_void,
            },
            ffi::RenderParam {
                type_: ffi::RENDER_PARAM_FLIP_Y,
                data: &mut flip as *mut _ as *mut c_void,
            },
            ffi::RenderParam { type_: 0, data: std::ptr::null_mut() },
        ];
        check(
            unsafe { ffi::mpv_render_context_render(self.ctx, params.as_ptr()) },
            "render",
        )
    }
}

impl Drop for RenderContext<'_> {
    fn drop(&mut self) {
        unsafe { ffi::mpv_render_context_free(self.ctx) };
    }
}

impl Drop for Mpv {
    fn drop(&mut self) {
        unsafe { ffi::mpv_terminate_destroy(self.ctx) };
    }
}

fn cstr(s: &str) -> CString {
    CString::new(s).unwrap_or_default()
}

fn check(code: c_int, what: &str) -> Result<(), String> {
    if code >= 0 {
        Ok(())
    } else {
        let msg = unsafe { CStr::from_ptr(ffi::mpv_error_string(code)) };
        Err(format!("{what}: {}", msg.to_string_lossy()))
    }
}

fn event_name(id: c_int) -> String {
    unsafe { CStr::from_ptr(ffi::mpv_event_name(id)) }
        .to_string_lossy()
        .into_owned()
}

/// An `edl://` URL that opens `url` as one track with `lang` and `title`
/// (mpv names external tracks after their URL otherwise). Values use the EDL
/// `%byte-length%` quoting, so they may contain `,`, `;` and `=`.
pub fn edl_track(url: &str, lang: &str, title: &str) -> String {
    let quote = |v: &str| format!("%{}%{v}", v.len());
    format!("edl://!track_meta,lang={},title={};{}", quote(lang), quote(title), quote(url))
}

/// A lazily opened WebVTT subtitle track: mpv fetches `url` only when the
/// track is selected (`!delay_open`, as mpv's ytdl_hook adds subtitles).
pub fn edl_sub_track(url: &str, lang: &str, title: &str) -> String {
    let quote = |v: &str| format!("%{}%{v}", v.len());
    format!(
        "edl://!no_clip;!delay_open,media_type=sub,codec=webvtt;!track_meta,lang={},title={};{}",
        quote(lang), quote(title), quote(url)
    )
}

#[cfg(test)]
mod tests {
    #[test]
    fn edl_track_quotes_values_by_byte_length() {
        assert_eq!(super::edl_track("https://h/v?a=1&b=2", "de", "Deutsch (Original)"),
            "edl://!track_meta,lang=%2%de,title=%18%Deutsch (Original);%19%https://h/v?a=1&b=2");
        assert_eq!(super::edl_track("u", "", "Ü"), "edl://!track_meta,lang=%0%,title=%2%Ü;%1%u");
    }
}