about summary refs log tree commit diff stats
path: root/crates/libmpv2/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/libmpv2/src')
-rw-r--r--crates/libmpv2/src/lib.rs175
-rw-r--r--crates/libmpv2/src/mpv.rs620
-rw-r--r--crates/libmpv2/src/mpv/errors.rs110
-rw-r--r--crates/libmpv2/src/mpv/events.rs383
-rw-r--r--crates/libmpv2/src/mpv/protocol.rs261
-rw-r--r--crates/libmpv2/src/mpv/render.rs406
-rw-r--r--crates/libmpv2/src/tests.rs222
7 files changed, 2177 insertions, 0 deletions
diff --git a/crates/libmpv2/src/lib.rs b/crates/libmpv2/src/lib.rs
new file mode 100644
index 0000000..4d8d18a
--- /dev/null
+++ b/crates/libmpv2/src/lib.rs
@@ -0,0 +1,175 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+//! This crate provides abstractions for
+//! [libmpv](https://github.com/mpv-player/mpv/tree/master/libmpv) of the
+//! [mpv media player](https://github.com/mpv-player/mpv).
+//!
+//! Libmpv requires `LC_NUMERIC` to be `C`, which should be the default value.
+//!
+//! Most of the documentation is paraphrased or even copied from the
+//! [mpv manual](https://mpv.io/manual/master/),
+//! if any questions arise it will probably answer them in much more depth than this documentation.
+//!
+//! # Examples
+//!
+//! See the 'examples' directory in the crate root.
+
+// Procedure for updating to new libmpv:
+// - make any nessecary API change (if so, bump crate version)
+// - update MPV_CLIENT_API consts in lib.rs
+// - run tests and examples to test whether they still work
+
+#![allow(non_upper_case_globals)]
+
+use std::fmt::Display;
+use std::os::raw as ctype;
+
+pub const MPV_CLIENT_API_MAJOR: ctype::c_ulong = 2;
+pub const MPV_CLIENT_API_MINOR: ctype::c_ulong = 2;
+pub const MPV_CLIENT_API_VERSION: ctype::c_ulong =
+    MPV_CLIENT_API_MAJOR << 16 | MPV_CLIENT_API_MINOR;
+
+mod mpv;
+#[cfg(test)]
+mod tests;
+
+pub use crate::mpv::*;
+
+/// A format mpv can use.
+pub use libmpv2_sys::mpv_format as MpvFormat;
+pub mod mpv_format {
+    pub use libmpv2_sys::mpv_format_MPV_FORMAT_DOUBLE as Double;
+    pub use libmpv2_sys::mpv_format_MPV_FORMAT_FLAG as Flag;
+    pub use libmpv2_sys::mpv_format_MPV_FORMAT_INT64 as Int64;
+    pub use libmpv2_sys::mpv_format_MPV_FORMAT_NODE as Node;
+    pub use libmpv2_sys::mpv_format_MPV_FORMAT_NODE_ARRAY as Array;
+    pub use libmpv2_sys::mpv_format_MPV_FORMAT_NODE_MAP as Map;
+    pub use libmpv2_sys::mpv_format_MPV_FORMAT_NONE as None;
+    pub use libmpv2_sys::mpv_format_MPV_FORMAT_OSD_STRING as OsdString;
+    pub use libmpv2_sys::mpv_format_MPV_FORMAT_STRING as String;
+}
+
+/// An libmpv2_sys mpv error.
+pub use libmpv2_sys::mpv_error as MpvError;
+pub mod mpv_error {
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_AO_INIT_FAILED as AoInitFailed;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_COMMAND as Command;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_EVENT_QUEUE_FULL as EventQueueFull;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_GENERIC as Generic;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_INVALID_PARAMETER as InvalidParameter;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_LOADING_FAILED as LoadingFailed;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_NOMEM as NoMem;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_NOTHING_TO_PLAY as NothingToPlay;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_NOT_IMPLEMENTED as NotImplemented;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_OPTION_ERROR as OptionError;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_OPTION_FORMAT as OptionFormat;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_OPTION_NOT_FOUND as OptionNotFound;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_PROPERTY_ERROR as PropertyError;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_PROPERTY_FORMAT as PropertyFormat;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_PROPERTY_NOT_FOUND as PropertyNotFound;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_PROPERTY_UNAVAILABLE as PropertyUnavailable;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_SUCCESS as Success;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_UNINITIALIZED as Uninitialized;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_UNKNOWN_FORMAT as UnknownFormat;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_UNSUPPORTED as Unsupported;
+    pub use libmpv2_sys::mpv_error_MPV_ERROR_VO_INIT_FAILED as VoInitFailed;
+}
+
+/// Log verbosity level.
+pub use libmpv2_sys::mpv_log_level as LogLevel;
+pub mod mpv_log_level {
+    pub use libmpv2_sys::mpv_log_level_MPV_LOG_LEVEL_DEBUG as Debug;
+    pub use libmpv2_sys::mpv_log_level_MPV_LOG_LEVEL_ERROR as Error;
+    pub use libmpv2_sys::mpv_log_level_MPV_LOG_LEVEL_FATAL as Fatal;
+    pub use libmpv2_sys::mpv_log_level_MPV_LOG_LEVEL_INFO as Info;
+    pub use libmpv2_sys::mpv_log_level_MPV_LOG_LEVEL_NONE as None;
+    pub use libmpv2_sys::mpv_log_level_MPV_LOG_LEVEL_TRACE as Trace;
+    pub use libmpv2_sys::mpv_log_level_MPV_LOG_LEVEL_V as V;
+    pub use libmpv2_sys::mpv_log_level_MPV_LOG_LEVEL_WARN as Warn;
+}
+
+/// The reason a file stopped.
+#[derive(Debug, Clone, Copy)]
+pub enum EndFileReason {
+    /**
+     * The end of file was reached. Sometimes this may also happen on
+     * incomplete or corrupted files, or if the network connection was
+     * interrupted when playing a remote file. It also happens if the
+     * playback range was restricted with --end or --frames or similar.
+     */
+    Eof,
+
+    /**
+     * Playback was stopped by an external action (e.g. playlist controls).
+     */
+    Stop,
+
+    /**
+     * Playback was stopped by the quit command or player shutdown.
+     */
+    Quit,
+
+    /**
+     * Some kind of error happened that lead to playback abort. Does not
+     * necessarily happen on incomplete or broken files (in these cases, both
+     * MPV_END_FILE_REASON_ERROR or MPV_END_FILE_REASON_EOF are possible).
+     *
+     * mpv_event_end_file.error will be set.
+     */
+    Error,
+
+    /**
+     * The file was a playlist or similar. When the playlist is read, its
+     * entries will be appended to the playlist after the entry of the current
+     * file, the entry of the current file is removed, and a MPV_EVENT_END_FILE
+     * event is sent with reason set to MPV_END_FILE_REASON_REDIRECT. Then
+     * playback continues with the playlist contents.
+     * Since API version 1.18.
+     */
+    Redirect,
+}
+
+impl From<libmpv2_sys::mpv_end_file_reason> for EndFileReason {
+    fn from(value: libmpv2_sys::mpv_end_file_reason) -> Self {
+        match value {
+            0 => Self::Eof,
+            2 => Self::Stop,
+            3 => Self::Quit,
+            4 => Self::Error,
+            5 => Self::Redirect,
+            _ => unreachable!("Other enum variants do not exist yet"),
+        }
+    }
+}
+
+impl Display for EndFileReason {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            EndFileReason::Eof => f.write_str("The end of file was reached.")?,
+            EndFileReason::Error => {
+                f.write_str(
+                    "Playback was stopped by an external action (e.g. playlist controls).",
+                )?;
+            }
+            EndFileReason::Quit => {
+                f.write_str("Playback was stopped by the quit command or player shutdown.")?;
+            }
+            EndFileReason::Redirect => {
+                f.write_str("Some kind of error happened that lead to playback abort.")?;
+            }
+            EndFileReason::Stop => {
+                f.write_str("The file was a playlist or similar.")?;
+            }
+        }
+
+        Ok(())
+    }
+}
diff --git a/crates/libmpv2/src/mpv.rs b/crates/libmpv2/src/mpv.rs
new file mode 100644
index 0000000..9d554a6
--- /dev/null
+++ b/crates/libmpv2/src/mpv.rs
@@ -0,0 +1,620 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+macro_rules! mpv_cstr_to_str {
+    ($cstr: expr) => {
+        std::ffi::CStr::from_ptr($cstr)
+            .to_str()
+            .map_err(Error::from)
+    };
+}
+
+mod errors;
+
+/// Event handling
+pub mod events;
+/// Custom protocols (`protocol://$url`) for playback
+#[cfg(feature = "protocols")]
+pub mod protocol;
+/// Custom rendering
+#[cfg(feature = "render")]
+pub mod render;
+
+use log::debug;
+
+pub use self::errors::*;
+use self::events::EventContext;
+use super::*;
+
+use std::{
+    ffi::CString,
+    mem::MaybeUninit,
+    ops::Deref,
+    ptr::{self, NonNull},
+    sync::atomic::AtomicBool,
+};
+
+fn mpv_err<T>(ret: T, err: ctype::c_int) -> Result<T> {
+    if err == 0 {
+        Ok(ret)
+    } else {
+        // debug!("Creating a raw error: {}", to_string_mpv_error(err));
+        Err(Error::Raw(err))
+    }
+}
+
+/// This trait describes which types are allowed to be passed to getter mpv APIs.
+pub unsafe trait GetData: Sized {
+    #[doc(hidden)]
+    fn get_from_c_void<T, F: FnMut(*mut ctype::c_void) -> Result<T>>(mut fun: F) -> Result<Self> {
+        let mut val = MaybeUninit::uninit();
+        let _ = fun(val.as_mut_ptr() as *mut _)?;
+        Ok(unsafe { val.assume_init() })
+    }
+    fn get_format() -> Format;
+}
+
+/// This trait describes which types are allowed to be passed to setter mpv APIs.
+pub unsafe trait SetData: Sized {
+    #[doc(hidden)]
+    fn call_as_c_void<T, F: FnMut(*mut ctype::c_void) -> Result<T>>(
+        mut self,
+        mut fun: F,
+    ) -> Result<T> {
+        fun(&mut self as *mut Self as _)
+    }
+    fn get_format() -> Format;
+}
+
+unsafe impl GetData for f64 {
+    fn get_format() -> Format {
+        Format::Double
+    }
+}
+
+unsafe impl SetData for f64 {
+    fn get_format() -> Format {
+        Format::Double
+    }
+}
+
+unsafe impl GetData for i64 {
+    fn get_format() -> Format {
+        Format::Int64
+    }
+}
+
+pub mod mpv_node {
+    use self::sys_node::SysMpvNode;
+    use crate::{Error, Format, GetData, Result};
+    use std::{mem::MaybeUninit, os::raw::c_void, ptr};
+
+    #[derive(Debug, Clone)]
+    pub enum MpvNode {
+        String(String),
+        Flag(bool),
+        Int64(i64),
+        Double(f64),
+        ArrayIter(MpvNodeArrayIter),
+        MapIter(MpvNodeMapIter),
+        None,
+    }
+
+    impl MpvNode {
+        pub fn bool(&self) -> Option<bool> {
+            if let MpvNode::Flag(value) = *self {
+                Some(value)
+            } else {
+                None
+            }
+        }
+        pub fn i64(&self) -> Option<i64> {
+            if let MpvNode::Int64(value) = *self {
+                Some(value)
+            } else {
+                None
+            }
+        }
+        pub fn f64(&self) -> Option<f64> {
+            if let MpvNode::Double(value) = *self {
+                Some(value)
+            } else {
+                None
+            }
+        }
+
+        pub fn str(&self) -> Option<&str> {
+            if let MpvNode::String(value) = self {
+                Some(value)
+            } else {
+                None
+            }
+        }
+
+        pub fn array(self) -> Option<MpvNodeArrayIter> {
+            if let MpvNode::ArrayIter(value) = self {
+                Some(value)
+            } else {
+                None
+            }
+        }
+
+        pub fn map(self) -> Option<MpvNodeMapIter> {
+            if let MpvNode::MapIter(value) = self {
+                Some(value)
+            } else {
+                None
+            }
+        }
+    }
+
+    impl PartialEq for MpvNode {
+        fn eq(&self, other: &Self) -> bool {
+            match (self, other) {
+                (Self::String(l0), Self::String(r0)) => l0 == r0,
+                (Self::Flag(l0), Self::Flag(r0)) => l0 == r0,
+                (Self::Int64(l0), Self::Int64(r0)) => l0 == r0,
+                (Self::Double(l0), Self::Double(r0)) => l0 == r0,
+                (Self::ArrayIter(l0), Self::ArrayIter(r0)) => l0.clone().eq(r0.clone()),
+                (Self::MapIter(l0), Self::MapIter(r0)) => l0.clone().eq(r0.clone()),
+                _ => core::mem::discriminant(self) == core::mem::discriminant(other),
+            }
+        }
+    }
+
+    #[derive(Debug)]
+    struct DropWrapper(libmpv2_sys::mpv_node);
+
+    impl Drop for DropWrapper {
+        fn drop(&mut self) {
+            unsafe {
+                libmpv2_sys::mpv_free_node_contents(&mut self.0 as *mut libmpv2_sys::mpv_node)
+            };
+        }
+    }
+
+    pub mod sys_node {
+        use super::{DropWrapper, MpvNode, MpvNodeArrayIter, MpvNodeMapIter};
+        use crate::{mpv_error, mpv_format, Error, Result};
+        use std::rc::Rc;
+
+        #[derive(Debug, Clone)]
+        pub struct SysMpvNode {
+            // Reference counted pointer to a parent node so it stays alive long enough.
+            //
+            // MPV has one big cleanup function that takes a node so store the parent node
+            // and force it to stay alive until the reference count hits 0.
+            parent: Option<Rc<DropWrapper>>,
+            node: libmpv2_sys::mpv_node,
+        }
+
+        impl SysMpvNode {
+            pub fn new(node: libmpv2_sys::mpv_node, drop: bool) -> Self {
+                Self {
+                    parent: if drop {
+                        Some(Rc::new(DropWrapper(node)))
+                    } else {
+                        None
+                    },
+                    node,
+                }
+            }
+
+            pub fn child(self: Self, node: libmpv2_sys::mpv_node) -> Self {
+                Self {
+                    parent: self.parent,
+                    node,
+                }
+            }
+
+            pub fn value(&self) -> Result<MpvNode> {
+                let node = self.node;
+                Ok(match node.format {
+                    mpv_format::Flag => MpvNode::Flag(unsafe { node.u.flag } == 1),
+                    mpv_format::Int64 => MpvNode::Int64(unsafe { node.u.int64 }),
+                    mpv_format::Double => MpvNode::Double(unsafe { node.u.double_ }),
+                    mpv_format::String => {
+                        let text = unsafe { mpv_cstr_to_str!(node.u.string) }?.to_owned();
+                        MpvNode::String(text)
+                    }
+                    mpv_format::Array => {
+                        let list = unsafe { *node.u.list };
+                        let iter = MpvNodeArrayIter {
+                            node: self.clone(),
+                            start: unsafe { *node.u.list }.values,
+                            end: unsafe { list.values.offset(list.num.try_into().unwrap()) },
+                        };
+                        return Ok(MpvNode::ArrayIter(iter));
+                    }
+
+                    mpv_format::Map => MpvNode::MapIter(MpvNodeMapIter {
+                        list: unsafe { *node.u.list },
+                        curr: 0,
+                        node: self.clone(),
+                    }),
+                    mpv_format::None => MpvNode::None,
+                    _ => return Err(Error::Raw(mpv_error::PropertyError)),
+                })
+            }
+        }
+    }
+
+    #[derive(Debug, Clone)]
+    pub struct MpvNodeArrayIter {
+        // Reference counted pointer to a parent node so it stays alive long enough.
+        //
+        // MPV has one big cleanup function that takes a node so store the parent node
+        // and force it to stay alive until the reference count hits 0.
+        node: SysMpvNode,
+        start: *const libmpv2_sys::mpv_node,
+        end: *const libmpv2_sys::mpv_node,
+    }
+
+    impl Iterator for MpvNodeArrayIter {
+        type Item = MpvNode;
+
+        fn next(&mut self) -> Option<Self::Item> {
+            if self.start == self.end {
+                None
+            } else {
+                unsafe {
+                    let result = ptr::read(self.start);
+                    let node = SysMpvNode::child(self.node.clone(), result);
+                    self.start = self.start.offset(1);
+                    node.value().ok()
+                }
+            }
+        }
+    }
+
+    #[derive(Debug, Clone)]
+    pub struct MpvNodeMapIter {
+        // Reference counted pointer to a parent node so it stays alive long enough.
+        //
+        // MPV has one big cleanup function that takes a node so store the parent node
+        // and force it to stay alive until the reference count hits 0.
+        node: SysMpvNode,
+        list: libmpv2_sys::mpv_node_list,
+        curr: usize,
+    }
+
+    impl Iterator for MpvNodeMapIter {
+        type Item = (String, MpvNode);
+
+        fn next(&mut self) -> Option<Self::Item> {
+            if self.curr >= self.list.num.try_into().unwrap() {
+                None
+            } else {
+                let offset = self.curr.try_into().unwrap();
+                let (key, value) = unsafe {
+                    (
+                        mpv_cstr_to_str!(*self.list.keys.offset(offset)),
+                        *self.list.values.offset(offset),
+                    )
+                };
+                self.curr += 1;
+                let node = SysMpvNode::child(self.node.clone(), value);
+                Some((key.unwrap().to_string(), node.value().unwrap()))
+            }
+        }
+    }
+
+    unsafe impl GetData for MpvNode {
+        fn get_from_c_void<T, F: FnMut(*mut c_void) -> Result<T>>(mut fun: F) -> Result<Self> {
+            let mut val = MaybeUninit::uninit();
+            fun(val.as_mut_ptr() as *mut _)?;
+            let sys_node = unsafe { val.assume_init() };
+            let node = SysMpvNode::new(sys_node, true);
+            node.value()
+        }
+
+        fn get_format() -> Format {
+            Format::Node
+        }
+    }
+}
+
+unsafe impl SetData for i64 {
+    fn get_format() -> Format {
+        Format::Int64
+    }
+}
+
+unsafe impl GetData for bool {
+    fn get_format() -> Format {
+        Format::Flag
+    }
+}
+
+unsafe impl SetData for bool {
+    fn call_as_c_void<T, F: FnMut(*mut ctype::c_void) -> Result<T>>(self, mut fun: F) -> Result<T> {
+        let mut cpy: i64 = if self { 1 } else { 0 };
+        fun(&mut cpy as *mut i64 as *mut _)
+    }
+
+    fn get_format() -> Format {
+        Format::Flag
+    }
+}
+
+unsafe impl GetData for String {
+    fn get_from_c_void<T, F: FnMut(*mut ctype::c_void) -> Result<T>>(mut fun: F) -> Result<String> {
+        let ptr = &mut ptr::null();
+        fun(ptr as *mut *const ctype::c_char as _)?;
+
+        let ret = unsafe { mpv_cstr_to_str!(*ptr) }?.to_owned();
+        unsafe { libmpv2_sys::mpv_free(*ptr as *mut _) };
+        Ok(ret)
+    }
+
+    fn get_format() -> Format {
+        Format::String
+    }
+}
+
+unsafe impl SetData for String {
+    fn call_as_c_void<T, F: FnMut(*mut ctype::c_void) -> Result<T>>(self, mut fun: F) -> Result<T> {
+        let string = CString::new(self)?;
+        fun((&mut string.as_ptr()) as *mut *const ctype::c_char as *mut _)
+    }
+
+    fn get_format() -> Format {
+        Format::String
+    }
+}
+
+/// Wrapper around an `&str` returned by mpv, that properly deallocates it with mpv's allocator.
+#[derive(Debug, Hash, Eq, PartialEq)]
+pub struct MpvStr<'a>(&'a str);
+impl<'a> Deref for MpvStr<'a> {
+    type Target = str;
+
+    fn deref(&self) -> &str {
+        self.0
+    }
+}
+impl<'a> Drop for MpvStr<'a> {
+    fn drop(&mut self) {
+        unsafe { libmpv2_sys::mpv_free(self.0.as_ptr() as *mut u8 as _) };
+    }
+}
+
+unsafe impl<'a> GetData for MpvStr<'a> {
+    fn get_from_c_void<T, F: FnMut(*mut ctype::c_void) -> Result<T>>(
+        mut fun: F,
+    ) -> Result<MpvStr<'a>> {
+        let ptr = &mut ptr::null();
+        let _ = fun(ptr as *mut *const ctype::c_char as _)?;
+
+        Ok(MpvStr(unsafe { mpv_cstr_to_str!(*ptr) }?))
+    }
+
+    fn get_format() -> Format {
+        Format::String
+    }
+}
+
+unsafe impl<'a> SetData for &'a str {
+    fn call_as_c_void<T, F: FnMut(*mut ctype::c_void) -> Result<T>>(self, mut fun: F) -> Result<T> {
+        let string = CString::new(self)?;
+        fun((&mut string.as_ptr()) as *mut *const ctype::c_char as *mut _)
+    }
+
+    fn get_format() -> Format {
+        Format::String
+    }
+}
+
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
+/// Subset of `mpv_format` used by the public API.
+pub enum Format {
+    String,
+    Flag,
+    Int64,
+    Double,
+    Node,
+}
+
+impl Format {
+    fn as_mpv_format(&self) -> MpvFormat {
+        match *self {
+            Format::String => mpv_format::String,
+            Format::Flag => mpv_format::Flag,
+            Format::Int64 => mpv_format::Int64,
+            Format::Double => mpv_format::Double,
+            Format::Node => mpv_format::Node,
+        }
+    }
+}
+
+/// Context passed to the `initializer` of `Mpv::with_initialzer`.
+pub struct MpvInitializer {
+    ctx: *mut libmpv2_sys::mpv_handle,
+}
+
+impl MpvInitializer {
+    /// Set the value of a property.
+    pub fn set_property<T: SetData>(&self, name: &str, data: T) -> Result<()> {
+        let name = CString::new(name)?;
+        let format = T::get_format().as_mpv_format() as _;
+        data.call_as_c_void(|ptr| {
+            mpv_err((), unsafe {
+                libmpv2_sys::mpv_set_property(self.ctx, name.as_ptr(), format, ptr)
+            })
+        })
+    }
+
+    /// Set the value of an option
+    pub fn set_option<T: SetData>(&self, name: &str, data: T) -> Result<()> {
+        let name = CString::new(name)?;
+        let format = T::get_format().as_mpv_format() as _;
+        data.call_as_c_void(|ptr| {
+            mpv_err((), unsafe {
+                libmpv2_sys::mpv_set_option(self.ctx, name.as_ptr(), format, ptr)
+            })
+        })
+    }
+}
+
+/// The central mpv context.
+pub struct Mpv {
+    /// The handle to the mpv core
+    pub ctx: NonNull<libmpv2_sys::mpv_handle>,
+    event_context: EventContext,
+    #[cfg(feature = "protocols")]
+    protocols_guard: AtomicBool,
+}
+
+unsafe impl Send for Mpv {}
+unsafe impl Sync for Mpv {}
+
+impl Drop for Mpv {
+    fn drop(&mut self) {
+        unsafe {
+            libmpv2_sys::mpv_terminate_destroy(self.ctx.as_ptr());
+        }
+    }
+}
+
+impl Mpv {
+    /// Create a new `Mpv`.
+    /// The default settings can be probed by running: `$ mpv --show-profile=libmpv`.
+    pub fn new() -> Result<Mpv> {
+        Mpv::with_initializer(|_| Ok(()))
+    }
+
+    /// Create a new `Mpv`.
+    /// The same as `Mpv::new`, but you can set properties before `Mpv` is initialized.
+    pub fn with_initializer<F: FnOnce(MpvInitializer) -> Result<()>>(
+        initializer: F,
+    ) -> Result<Mpv> {
+        let api_version = unsafe { libmpv2_sys::mpv_client_api_version() };
+        if crate::MPV_CLIENT_API_MAJOR != api_version >> 16 {
+            return Err(Error::VersionMismatch {
+                linked: crate::MPV_CLIENT_API_VERSION,
+                loaded: api_version,
+            });
+        }
+
+        let ctx = unsafe { libmpv2_sys::mpv_create() };
+        if ctx.is_null() {
+            return Err(Error::Null);
+        }
+
+        initializer(MpvInitializer { ctx })?;
+        mpv_err((), unsafe { libmpv2_sys::mpv_initialize(ctx) }).map_err(|err| {
+            unsafe { libmpv2_sys::mpv_terminate_destroy(ctx) };
+            err
+        })?;
+
+        let ctx = unsafe { NonNull::new_unchecked(ctx) };
+
+        Ok(Mpv {
+            ctx,
+            event_context: EventContext::new(ctx),
+            #[cfg(feature = "protocols")]
+            protocols_guard: AtomicBool::new(false),
+        })
+    }
+
+    /// Execute a command
+    pub fn execute(&self, name: &str, args: &[&str]) -> Result<()> {
+        if args.is_empty() {
+            debug!("Running mpv command: '{}'", name);
+        } else {
+            debug!("Running mpv command: '{} {}'", name, args.join(" "));
+        }
+
+        self.command(name, args)?;
+
+        Ok(())
+    }
+
+    /// Load a configuration file. The path has to be absolute, and a file.
+    pub fn load_config(&self, path: &str) -> Result<()> {
+        let file = CString::new(path)?.into_raw();
+        let ret = mpv_err((), unsafe {
+            libmpv2_sys::mpv_load_config_file(self.ctx.as_ptr(), file)
+        });
+        unsafe {
+            drop(CString::from_raw(file));
+        };
+        ret
+    }
+
+    pub fn event_context(&self) -> &EventContext {
+        &self.event_context
+    }
+
+    pub fn event_context_mut(&mut self) -> &mut EventContext {
+        &mut self.event_context
+    }
+
+    /// Send a command to the `Mpv` instance. This uses `mpv_command_string` internally,
+    /// so that the syntax is the same as described in the [manual for the input.conf](https://mpv.io/manual/master/#list-of-input-commands).
+    ///
+    /// Note that you may have to escape strings with `""` when they contain spaces.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// # use libmpv2::{Mpv};
+    /// # use libmpv2::mpv_node::MpvNode;
+    /// # use std::collections::HashMap;
+    /// mpv.command("loadfile", &["test-data/jellyfish.mp4", "append-play"]).unwrap();
+    /// # let node = mpv.get_property::<MpvNode>("playlist").unwrap();
+    /// # let mut list = node.array().unwrap().collect::<Vec<_>>();
+    /// # let map = list.pop().unwrap().map().unwrap().collect::<HashMap<_, _>>();
+    /// # assert_eq!(map, HashMap::from([(String::from("id"), MpvNode::Int64(1)), (String::from("current"), MpvNode::Flag(true)), (String::from("filename"), MpvNode::String(String::from("test-data/jellyfish.mp4")))]));
+    /// ```
+    pub fn command(&self, name: &str, args: &[&str]) -> Result<()> {
+        let mut cmd = name.to_owned();
+
+        for elem in args {
+            cmd.push(' ');
+            cmd.push_str(elem);
+        }
+
+        let raw = CString::new(cmd)?;
+        mpv_err((), unsafe {
+            libmpv2_sys::mpv_command_string(self.ctx.as_ptr(), raw.as_ptr())
+        })
+    }
+
+    /// Set the value of a property.
+    pub fn set_property<T: SetData>(&self, name: &str, data: T) -> Result<()> {
+        let name = CString::new(name)?;
+        let format = T::get_format().as_mpv_format() as _;
+        data.call_as_c_void(|ptr| {
+            mpv_err((), unsafe {
+                libmpv2_sys::mpv_set_property(self.ctx.as_ptr(), name.as_ptr(), format, ptr)
+            })
+        })
+    }
+
+    /// Get the value of a property.
+    pub fn get_property<T: GetData>(&self, name: &str) -> Result<T> {
+        let name = CString::new(name)?;
+
+        let format = T::get_format().as_mpv_format() as _;
+        T::get_from_c_void(|ptr| {
+            mpv_err((), unsafe {
+                libmpv2_sys::mpv_get_property(self.ctx.as_ptr(), name.as_ptr(), format, ptr)
+            })
+        })
+    }
+
+    /// Internal time in microseconds, this has an arbitrary offset, and will never go backwards.
+    ///
+    /// This can be called at any time, even if it was stated that no API function should be called.
+    pub fn get_internal_time(&self) -> i64 {
+        unsafe { libmpv2_sys::mpv_get_time_us(self.ctx.as_ptr()) }
+    }
+}
diff --git a/crates/libmpv2/src/mpv/errors.rs b/crates/libmpv2/src/mpv/errors.rs
new file mode 100644
index 0000000..a2baee5
--- /dev/null
+++ b/crates/libmpv2/src/mpv/errors.rs
@@ -0,0 +1,110 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use std::{ffi::NulError, os::raw as ctype, str::Utf8Error};
+
+use thiserror::Error;
+
+use super::mpv_error;
+
+#[allow(missing_docs)]
+pub type Result<T> = ::std::result::Result<T, Error>;
+
+#[derive(Error, Debug)]
+pub enum Error {
+    #[error("loading file failed: {error}")]
+    Loadfile { error: String },
+
+    #[error("version mismatch detected! Linked version ({linked}) is unequal to the loaded version ({loaded})")]
+    VersionMismatch {
+        linked: ctype::c_ulong,
+        loaded: ctype::c_ulong,
+    },
+
+    #[error("invalid utf8 returned")]
+    InvalidUtf8,
+
+    #[error("null pointer returned")]
+    Null,
+
+    #[error("raw error returned: {}", to_string_mpv_error(*(.0)))]
+    Raw(crate::MpvError),
+}
+
+impl From<NulError> for Error {
+    fn from(_other: NulError) -> Error {
+        Error::Null
+    }
+}
+
+impl From<Utf8Error> for Error {
+    fn from(_other: Utf8Error) -> Error {
+        Error::InvalidUtf8
+    }
+}
+impl From<crate::MpvError> for Error {
+    fn from(other: crate::MpvError) -> Error {
+        Error::Raw(other)
+    }
+}
+
+pub(crate) fn to_string_mpv_error(num: crate::MpvError) -> String {
+    let (error, help) = to_string_mpv_error_raw(num);
+
+    if help.is_empty() {
+        error.to_owned()
+    } else {
+        format!("{} ({})", error, help)
+    }
+}
+
+fn to_string_mpv_error_raw(num: crate::MpvError) -> (&'static str, &'static str) {
+    // debug!("Turning error num '{}' to a string.", num);
+
+    match num {
+        mpv_error::EventQueueFull => (
+            "The event ringbuffer is full.",
+            "This means the client is choked, and can't receive any events. This can happen when too many asynchronous requests have been made, but not answered. Probably never happens in practice, unless the mpv core is frozen for some reason, and the client keeps making asynchronous requests. (Bugs in the client API implementation could also trigger this, e.g. if events become \"lost\".)",
+        ),
+
+        mpv_error::NoMem => ("Memory allocation failed.", ""),
+
+        mpv_error::Uninitialized => ("The mpv core wasn't configured and initialized yet", " See the notes in mpv_create()."),
+
+        mpv_error::InvalidParameter => ("Generic catch-all error if a parameter is set to an invalid or unsupported value.", "This is used if there is no better error code."),
+
+        mpv_error::OptionNotFound => ("Trying to set an option that doesn't exist.", ""),
+        mpv_error::OptionFormat => ("Trying to set an option using an unsupported MPV_FORMAT.", ""),
+        mpv_error::OptionError => ("Setting the option failed", " Typically this happens if the provided option value could not be parsed."),
+
+        mpv_error::PropertyNotFound => ("The accessed property doesn't exist.", ""),
+        mpv_error::PropertyFormat => ("Trying to set or get a property using an unsupported MPV_FORMAT.", ""),
+        mpv_error::PropertyUnavailable => ("The property exists, but is not available", "This usually happens when the associated subsystem is not active, e.g. querying audio parameters while audio is disabled."),
+        mpv_error::PropertyError => ("Error setting or getting a property.", ""),
+
+        mpv_error::Command => ("General error when running a command with mpv_command and similar.", ""),
+
+        mpv_error::LoadingFailed => ("Generic error on loading (usually used with mpv_event_end_file.error).", ""),
+
+        mpv_error::AoInitFailed => ("Initializing the audio output failed.", ""),
+        mpv_error::VoInitFailed => ("Initializing the video output failed.", ""),
+
+        mpv_error::NothingToPlay => ("There was no audio or video data to play", "This also happens if the file was recognized, but did not contain any audio or video streams, or no streams were selected."),
+
+        mpv_error::UnknownFormat => ("     * When trying to load the file, the file format could not be determined, or the file was too broken to open it.", ""),
+
+        mpv_error::Generic => ("Generic error for signaling that certain system requirements are not fulfilled.", ""),
+        mpv_error::NotImplemented => ("The API function which was called is a stub only", ""),
+        mpv_error::Unsupported => ("Unspecified error.", ""),
+
+        mpv_error::Success => unreachable!("This is not an error. It's just here, to ensure that the 0 case marks an success'"),
+        _ => unreachable!("Mpv seems to have changed it's constants."),
+    }
+}
diff --git a/crates/libmpv2/src/mpv/events.rs b/crates/libmpv2/src/mpv/events.rs
new file mode 100644
index 0000000..cbe1ef3
--- /dev/null
+++ b/crates/libmpv2/src/mpv/events.rs
@@ -0,0 +1,383 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use crate::mpv_node::sys_node::SysMpvNode;
+use crate::{mpv::mpv_err, *};
+
+use std::ffi::{c_void, CString};
+use std::os::raw as ctype;
+use std::ptr::NonNull;
+use std::slice;
+
+/// An `Event`'s ID.
+pub use libmpv2_sys::mpv_event_id as EventId;
+
+use self::mpv_node::MpvNode;
+pub mod mpv_event_id {
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_AUDIO_RECONFIG as AudioReconfig;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_CLIENT_MESSAGE as ClientMessage;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_COMMAND_REPLY as CommandReply;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_END_FILE as EndFile;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_FILE_LOADED as FileLoaded;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_GET_PROPERTY_REPLY as GetPropertyReply;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_HOOK as Hook;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_LOG_MESSAGE as LogMessage;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_NONE as None;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_PLAYBACK_RESTART as PlaybackRestart;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_PROPERTY_CHANGE as PropertyChange;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_QUEUE_OVERFLOW as QueueOverflow;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_SEEK as Seek;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_SET_PROPERTY_REPLY as SetPropertyReply;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_SHUTDOWN as Shutdown;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_START_FILE as StartFile;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_TICK as Tick;
+    pub use libmpv2_sys::mpv_event_id_MPV_EVENT_VIDEO_RECONFIG as VideoReconfig;
+}
+
+#[derive(Debug)]
+/// Data that is returned by both `GetPropertyReply` and `PropertyChange` events.
+pub enum PropertyData<'a> {
+    Str(&'a str),
+    OsdStr(&'a str),
+    Flag(bool),
+    Int64(i64),
+    Double(ctype::c_double),
+    Node(MpvNode),
+}
+
+impl<'a> PropertyData<'a> {
+    // SAFETY: meant to extract the data from an event property. See `mpv_event_property` in
+    // `client.h`
+    unsafe fn from_raw(format: MpvFormat, ptr: *mut ctype::c_void) -> Result<PropertyData<'a>> {
+        assert!(!ptr.is_null());
+        match format {
+            mpv_format::Flag => Ok(PropertyData::Flag(*(ptr as *mut bool))),
+            mpv_format::String => {
+                let char_ptr = *(ptr as *mut *mut ctype::c_char);
+                Ok(PropertyData::Str(mpv_cstr_to_str!(char_ptr)?))
+            }
+            mpv_format::OsdString => {
+                let char_ptr = *(ptr as *mut *mut ctype::c_char);
+                Ok(PropertyData::OsdStr(mpv_cstr_to_str!(char_ptr)?))
+            }
+            mpv_format::Double => Ok(PropertyData::Double(*(ptr as *mut f64))),
+            mpv_format::Int64 => Ok(PropertyData::Int64(*(ptr as *mut i64))),
+            mpv_format::Node => {
+                let sys_node = *(ptr as *mut libmpv2_sys::mpv_node);
+                let node = SysMpvNode::new(sys_node, false);
+                return Ok(PropertyData::Node(node.value().unwrap()));
+            }
+            mpv_format::None => unreachable!(),
+            _ => unimplemented!(),
+        }
+    }
+}
+
+pub type PlaylistEntryId = i64;
+
+#[derive(Debug)]
+pub enum Event<'a> {
+    /// Received when the player is shutting down
+    Shutdown,
+    /// *Has not been tested*, received when explicitly asked to MPV
+    LogMessage {
+        prefix: &'a str,
+        level: &'a str,
+        text: &'a str,
+        log_level: LogLevel,
+    },
+    /// Received when using get_property_async
+    GetPropertyReply {
+        name: &'a str,
+        result: PropertyData<'a>,
+        reply_userdata: u64,
+    },
+    /// Received when using set_property_async
+    SetPropertyReply(u64),
+    /// Received when using command_async
+    CommandReply(u64),
+    /// Event received when a new file is playing
+    StartFile(PlaylistEntryId),
+    /// Event received when the file being played currently has stopped, for an error or not
+    EndFile(EndFileReason),
+    /// Event received when a file has been *loaded*, but has not been started
+    FileLoaded,
+    ClientMessage(Vec<&'a str>),
+    VideoReconfig,
+    AudioReconfig,
+    /// The player changed current position
+    Seek,
+    PlaybackRestart,
+    /// Received when used with observe_property
+    PropertyChange {
+        name: &'a str,
+        change: PropertyData<'a>,
+        reply_userdata: u64,
+    },
+    /// Received when the Event Queue is full
+    QueueOverflow,
+    /// A deprecated event
+    Deprecated(libmpv2_sys::mpv_event),
+}
+
+unsafe extern "C" fn wu_wrapper<F: Fn() + Send + 'static>(ctx: *mut c_void) {
+    if ctx.is_null() {
+        panic!("ctx for wakeup wrapper is NULL");
+    }
+
+    (*(ctx as *mut F))();
+}
+
+/// Context to listen to events.
+pub struct EventContext {
+    ctx: NonNull<libmpv2_sys::mpv_handle>,
+    wakeup_callback_cleanup: Option<Box<dyn FnOnce()>>,
+}
+
+unsafe impl Send for EventContext {}
+
+impl EventContext {
+    pub fn new(ctx: NonNull<libmpv2_sys::mpv_handle>) -> Self {
+        EventContext {
+            ctx,
+            wakeup_callback_cleanup: None,
+        }
+    }
+
+    /// Enable an event.
+    pub fn enable_event(&self, ev: events::EventId) -> Result<()> {
+        mpv_err((), unsafe {
+            libmpv2_sys::mpv_request_event(self.ctx.as_ptr(), ev, 1)
+        })
+    }
+
+    /// Enable all, except deprecated, events.
+    pub fn enable_all_events(&self) -> Result<()> {
+        for i in (2..9).chain(16..19).chain(20..23).chain(24..26) {
+            self.enable_event(i)?;
+        }
+        Ok(())
+    }
+
+    /// Disable an event.
+    pub fn disable_event(&self, ev: events::EventId) -> Result<()> {
+        mpv_err((), unsafe {
+            libmpv2_sys::mpv_request_event(self.ctx.as_ptr(), ev, 0)
+        })
+    }
+
+    /// Diable all deprecated events.
+    pub fn disable_deprecated_events(&self) -> Result<()> {
+        self.disable_event(libmpv2_sys::mpv_event_id_MPV_EVENT_IDLE)?;
+        Ok(())
+    }
+
+    /// Diable all events.
+    pub fn disable_all_events(&self) -> Result<()> {
+        for i in 2..26 {
+            self.disable_event(i as _)?;
+        }
+        Ok(())
+    }
+
+    /// Observe `name` property for changes. `id` can be used to unobserve this (or many) properties
+    /// again.
+    pub fn observe_property(&self, name: &str, format: Format, id: u64) -> Result<()> {
+        let name = CString::new(name)?;
+        mpv_err((), unsafe {
+            libmpv2_sys::mpv_observe_property(
+                self.ctx.as_ptr(),
+                id,
+                name.as_ptr(),
+                format.as_mpv_format() as _,
+            )
+        })
+    }
+
+    /// Unobserve any property associated with `id`.
+    pub fn unobserve_property(&self, id: u64) -> Result<()> {
+        mpv_err((), unsafe {
+            libmpv2_sys::mpv_unobserve_property(self.ctx.as_ptr(), id)
+        })
+    }
+
+    /// Wait for `timeout` seconds for an `Event`. Passing `0` as `timeout` will poll.
+    /// For more information, as always, see the mpv-sys docs of `mpv_wait_event`.
+    ///
+    /// This function is intended to be called repeatedly in a wait-event loop.
+    ///
+    /// Returns `Some(Err(...))` if there was invalid utf-8, or if either an
+    /// `MPV_EVENT_GET_PROPERTY_REPLY`, `MPV_EVENT_SET_PROPERTY_REPLY`, `MPV_EVENT_COMMAND_REPLY`,
+    /// or `MPV_EVENT_PROPERTY_CHANGE` event failed, or if `MPV_EVENT_END_FILE` reported an error.
+    pub fn wait_event(&mut self, timeout: f64) -> Option<Result<Event>> {
+        let event = unsafe { *libmpv2_sys::mpv_wait_event(self.ctx.as_ptr(), timeout) };
+
+        // debug!("Got an event from mpv: {:#?}", event);
+
+        if event.event_id != mpv_event_id::None {
+            if let Err(e) = mpv_err((), event.error) {
+                return Some(Err(e));
+            }
+        }
+
+        match event.event_id {
+            mpv_event_id::None => None,
+            mpv_event_id::Shutdown => Some(Ok(Event::Shutdown)),
+            mpv_event_id::LogMessage => {
+                let log_message =
+                    unsafe { *(event.data as *mut libmpv2_sys::mpv_event_log_message) };
+
+                let prefix = unsafe { mpv_cstr_to_str!(log_message.prefix) };
+                Some(prefix.and_then(|prefix| {
+                    Ok(Event::LogMessage {
+                        prefix,
+                        level: unsafe { mpv_cstr_to_str!(log_message.level)? },
+                        text: unsafe { mpv_cstr_to_str!(log_message.text)? },
+                        log_level: log_message.log_level,
+                    })
+                }))
+            }
+            mpv_event_id::GetPropertyReply => {
+                let property = unsafe { *(event.data as *mut libmpv2_sys::mpv_event_property) };
+
+                let name = unsafe { mpv_cstr_to_str!(property.name) };
+                Some(name.and_then(|name| {
+                    // SAFETY: safe because we are passing format + data from an mpv_event_property
+                    let result = unsafe { PropertyData::from_raw(property.format, property.data) }?;
+
+                    Ok(Event::GetPropertyReply {
+                        name,
+                        result,
+                        reply_userdata: event.reply_userdata,
+                    })
+                }))
+            }
+            mpv_event_id::SetPropertyReply => Some(mpv_err(
+                Event::SetPropertyReply(event.reply_userdata),
+                event.error,
+            )),
+            mpv_event_id::CommandReply => Some(mpv_err(
+                Event::CommandReply(event.reply_userdata),
+                event.error,
+            )),
+            mpv_event_id::StartFile => {
+                let playlist_id = unsafe { *(event.data as *mut i64) };
+
+                Some(Ok(Event::StartFile(playlist_id)))
+            }
+            mpv_event_id::EndFile => {
+                let end_file = unsafe { *(event.data as *mut libmpv2_sys::mpv_event_end_file) };
+
+                // debug!("Got an end file event, with error code '{:#?}'", end_file);
+
+                if let Err(e) = mpv_err((), end_file.error) {
+                    Some(Err(e))
+                } else {
+                    Some(Ok(Event::EndFile(end_file.reason.into())))
+                }
+            }
+            mpv_event_id::FileLoaded => Some(Ok(Event::FileLoaded)),
+            mpv_event_id::ClientMessage => {
+                let client_message =
+                    unsafe { *(event.data as *mut libmpv2_sys::mpv_event_client_message) };
+                let messages = unsafe {
+                    slice::from_raw_parts_mut(client_message.args, client_message.num_args as _)
+                };
+                Some(Ok(Event::ClientMessage(
+                    messages
+                        .iter()
+                        .map(|msg| unsafe { mpv_cstr_to_str!(*msg) })
+                        .collect::<Result<Vec<_>>>()
+                        .unwrap(),
+                )))
+            }
+            mpv_event_id::VideoReconfig => Some(Ok(Event::VideoReconfig)),
+            mpv_event_id::AudioReconfig => Some(Ok(Event::AudioReconfig)),
+            mpv_event_id::Seek => Some(Ok(Event::Seek)),
+            mpv_event_id::PlaybackRestart => Some(Ok(Event::PlaybackRestart)),
+            mpv_event_id::PropertyChange => {
+                let property = unsafe { *(event.data as *mut libmpv2_sys::mpv_event_property) };
+
+                // This happens if the property is not available. For example,
+                // if you reached EndFile while observing a property.
+                if property.format == mpv_format::None {
+                    None
+                } else {
+                    let name = unsafe { mpv_cstr_to_str!(property.name) };
+                    Some(name.and_then(|name| {
+                        // SAFETY: safe because we are passing format + data from an mpv_event_property
+                        let change =
+                            unsafe { PropertyData::from_raw(property.format, property.data) }?;
+
+                        Ok(Event::PropertyChange {
+                            name,
+                            change,
+                            reply_userdata: event.reply_userdata,
+                        })
+                    }))
+                }
+            }
+            mpv_event_id::QueueOverflow => Some(Ok(Event::QueueOverflow)),
+            _ => Some(Ok(Event::Deprecated(event))),
+        }
+    }
+
+    /// Set a custom function that should be called when there are new events. Use this if
+    /// blocking in [wait_event](#method.wait_event) to wait for new events is not feasible.
+    ///
+    /// Keep in mind that the callback will be called from foreign threads. You must not make
+    /// any assumptions of the environment, and you must return as soon as possible (i.e. no
+    /// long blocking waits). Exiting the callback through any other means than a normal return
+    /// is forbidden (no throwing exceptions, no `longjmp()` calls). You must not change any
+    /// local thread state (such as the C floating point environment).
+    ///
+    /// You are not allowed to call any client API functions inside of the callback. In
+    /// particular, you should not do any processing in the callback, but wake up another
+    /// thread that does all the work. The callback is meant strictly for notification only,
+    /// and is called from arbitrary core parts of the player, that make no considerations for
+    /// reentrant API use or allowing the callee to spend a lot of time doing other things.
+    /// Keep in mind that it’s also possible that the callback is called from a thread while a
+    /// mpv API function is called (i.e. it can be reentrant).
+    ///
+    /// In general, the client API expects you to call [wait_event](#method.wait_event) to receive
+    /// notifications, and the wakeup callback is merely a helper utility to make this easier in
+    /// certain situations. Note that it’s possible that there’s only one wakeup callback
+    /// invocation for multiple events. You should call [wait_event](#method.wait_event) with no timeout until
+    /// `None` is returned, at which point the event queue is empty.
+    ///
+    /// If you actually want to do processing in a callback, spawn a thread that does nothing but
+    /// call [wait_event](#method.wait_event) in a loop and dispatches the result to a callback.
+    ///
+    /// Only one wakeup callback can be set.
+    pub fn set_wakeup_callback<F: Fn() + Send + 'static>(&mut self, callback: F) {
+        if let Some(wakeup_callback_cleanup) = self.wakeup_callback_cleanup.take() {
+            wakeup_callback_cleanup();
+        }
+        let raw_callback = Box::into_raw(Box::new(callback));
+        self.wakeup_callback_cleanup = Some(Box::new(move || unsafe {
+            drop(Box::from_raw(raw_callback));
+        }) as Box<dyn FnOnce()>);
+        unsafe {
+            libmpv2_sys::mpv_set_wakeup_callback(
+                self.ctx.as_ptr(),
+                Some(wu_wrapper::<F>),
+                raw_callback as *mut c_void,
+            );
+        }
+    }
+}
+
+impl Drop for EventContext {
+    fn drop(&mut self) {
+        if let Some(wakeup_callback_cleanup) = self.wakeup_callback_cleanup.take() {
+            wakeup_callback_cleanup();
+        }
+    }
+}
diff --git a/crates/libmpv2/src/mpv/protocol.rs b/crates/libmpv2/src/mpv/protocol.rs
new file mode 100644
index 0000000..4ae4f16
--- /dev/null
+++ b/crates/libmpv2/src/mpv/protocol.rs
@@ -0,0 +1,261 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use super::*;
+
+use std::alloc::{self, Layout};
+use std::marker::PhantomData;
+use std::mem;
+use std::os::raw as ctype;
+use std::panic;
+use std::panic::RefUnwindSafe;
+use std::slice;
+use std::sync::{atomic::Ordering, Mutex};
+
+impl Mpv {
+    /// Create a context with which custom protocols can be registered.
+    ///
+    /// # Panics
+    /// Panics if a context already exists
+    pub fn create_protocol_context<T, U>(&self) -> ProtocolContext<T, U>
+    where
+        T: RefUnwindSafe,
+        U: RefUnwindSafe,
+    {
+        match self.protocols_guard.compare_exchange(
+            false,
+            true,
+            Ordering::AcqRel,
+            Ordering::Acquire,
+        ) {
+            Ok(_) => ProtocolContext::new(self.ctx, PhantomData::<&Self>),
+            Err(_) => panic!("A protocol context already exists"),
+        }
+    }
+}
+
+/// Return a persistent `T` that is passed to all other `Stream*` functions, panic on errors.
+pub type StreamOpen<T, U> = fn(&mut U, &str) -> T;
+/// Do any necessary cleanup.
+pub type StreamClose<T> = fn(Box<T>);
+/// Seek to the given offset. Return the new offset, or either `MpvError::Generic` if seeking
+/// failed or panic.
+pub type StreamSeek<T> = fn(&mut T, i64) -> i64;
+/// Target buffer with fixed capacity.
+/// Return either the number of read bytes, `0` on EOF, or either `-1` or panic on error.
+pub type StreamRead<T> = fn(&mut T, &mut [ctype::c_char]) -> i64;
+/// Return the total size of the stream in bytes. Panic on error.
+pub type StreamSize<T> = fn(&mut T) -> i64;
+
+unsafe extern "C" fn open_wrapper<T, U>(
+    user_data: *mut ctype::c_void,
+    uri: *mut ctype::c_char,
+    info: *mut libmpv2_sys::mpv_stream_cb_info,
+) -> ctype::c_int
+where
+    T: RefUnwindSafe,
+    U: RefUnwindSafe,
+{
+    let data = user_data as *mut ProtocolData<T, U>;
+
+    (*info).cookie = user_data;
+    (*info).read_fn = Some(read_wrapper::<T, U>);
+    (*info).seek_fn = Some(seek_wrapper::<T, U>);
+    (*info).size_fn = Some(size_wrapper::<T, U>);
+    (*info).close_fn = Some(close_wrapper::<T, U>);
+
+    let ret = panic::catch_unwind(|| {
+        let uri = mpv_cstr_to_str!(uri as *const _).unwrap();
+        ptr::write(
+            (*data).cookie,
+            ((*data).open_fn)(&mut (*data).user_data, uri),
+        );
+    });
+
+    if ret.is_ok() {
+        0
+    } else {
+        mpv_error::Generic as _
+    }
+}
+
+unsafe extern "C" fn read_wrapper<T, U>(
+    cookie: *mut ctype::c_void,
+    buf: *mut ctype::c_char,
+    nbytes: u64,
+) -> i64
+where
+    T: RefUnwindSafe,
+    U: RefUnwindSafe,
+{
+    let data = cookie as *mut ProtocolData<T, U>;
+
+    let ret = panic::catch_unwind(|| {
+        let slice = slice::from_raw_parts_mut(buf, nbytes as _);
+        ((*data).read_fn)(&mut *(*data).cookie, slice)
+    });
+    if let Ok(ret) = ret {
+        ret
+    } else {
+        -1
+    }
+}
+
+unsafe extern "C" fn seek_wrapper<T, U>(cookie: *mut ctype::c_void, offset: i64) -> i64
+where
+    T: RefUnwindSafe,
+    U: RefUnwindSafe,
+{
+    let data = cookie as *mut ProtocolData<T, U>;
+
+    if (*data).seek_fn.is_none() {
+        return mpv_error::Unsupported as _;
+    }
+
+    let ret =
+        panic::catch_unwind(|| (*(*data).seek_fn.as_ref().unwrap())(&mut *(*data).cookie, offset));
+    if let Ok(ret) = ret {
+        ret
+    } else {
+        mpv_error::Generic as _
+    }
+}
+
+unsafe extern "C" fn size_wrapper<T, U>(cookie: *mut ctype::c_void) -> i64
+where
+    T: RefUnwindSafe,
+    U: RefUnwindSafe,
+{
+    let data = cookie as *mut ProtocolData<T, U>;
+
+    if (*data).size_fn.is_none() {
+        return mpv_error::Unsupported as _;
+    }
+
+    let ret = panic::catch_unwind(|| (*(*data).size_fn.as_ref().unwrap())(&mut *(*data).cookie));
+    if let Ok(ret) = ret {
+        ret
+    } else {
+        mpv_error::Unsupported as _
+    }
+}
+
+#[allow(unused_must_use)]
+unsafe extern "C" fn close_wrapper<T, U>(cookie: *mut ctype::c_void)
+where
+    T: RefUnwindSafe,
+    U: RefUnwindSafe,
+{
+    let data = Box::from_raw(cookie as *mut ProtocolData<T, U>);
+
+    panic::catch_unwind(|| ((*data).close_fn)(Box::from_raw((*data).cookie)));
+}
+
+struct ProtocolData<T, U> {
+    cookie: *mut T,
+    user_data: U,
+
+    open_fn: StreamOpen<T, U>,
+    close_fn: StreamClose<T>,
+    read_fn: StreamRead<T>,
+    seek_fn: Option<StreamSeek<T>>,
+    size_fn: Option<StreamSize<T>>,
+}
+
+/// This context holds state relevant to custom protocols.
+/// It is created by calling `Mpv::create_protocol_context`.
+pub struct ProtocolContext<'parent, T: RefUnwindSafe, U: RefUnwindSafe> {
+    ctx: NonNull<libmpv2_sys::mpv_handle>,
+    protocols: Mutex<Vec<Protocol<T, U>>>,
+    _does_not_outlive: PhantomData<&'parent Mpv>,
+}
+
+unsafe impl<'parent, T: RefUnwindSafe, U: RefUnwindSafe> Send for ProtocolContext<'parent, T, U> {}
+unsafe impl<'parent, T: RefUnwindSafe, U: RefUnwindSafe> Sync for ProtocolContext<'parent, T, U> {}
+
+impl<'parent, T: RefUnwindSafe, U: RefUnwindSafe> ProtocolContext<'parent, T, U> {
+    fn new(
+        ctx: NonNull<libmpv2_sys::mpv_handle>,
+        marker: PhantomData<&'parent Mpv>,
+    ) -> ProtocolContext<'parent, T, U> {
+        ProtocolContext {
+            ctx,
+            protocols: Mutex::new(Vec::new()),
+            _does_not_outlive: marker,
+        }
+    }
+
+    /// Register a custom `Protocol`. Once a protocol has been registered, it lives as long as
+    /// `Mpv`.
+    ///
+    /// Returns `Error::Mpv(MpvError::InvalidParameter)` if a protocol with the same name has
+    /// already been registered.
+    pub fn register(&self, protocol: Protocol<T, U>) -> Result<()> {
+        let mut protocols = self.protocols.lock().unwrap();
+        protocol.register(self.ctx.as_ptr())?;
+        protocols.push(protocol);
+        Ok(())
+    }
+}
+
+/// `Protocol` holds all state used by a custom protocol.
+pub struct Protocol<T: Sized + RefUnwindSafe, U: RefUnwindSafe> {
+    name: String,
+    data: *mut ProtocolData<T, U>,
+}
+
+impl<T: RefUnwindSafe, U: RefUnwindSafe> Protocol<T, U> {
+    /// `name` is the prefix of the protocol, e.g. `name://path`.
+    ///
+    /// `user_data` is data that will be passed to `open_fn`.
+    ///
+    /// # Safety
+    /// Do not call libmpv functions in any supplied function.
+    /// All panics of the provided functions are catched and can be used as generic error returns.
+    pub unsafe fn new(
+        name: String,
+        user_data: U,
+        open_fn: StreamOpen<T, U>,
+        close_fn: StreamClose<T>,
+        read_fn: StreamRead<T>,
+        seek_fn: Option<StreamSeek<T>>,
+        size_fn: Option<StreamSize<T>>,
+    ) -> Protocol<T, U> {
+        let c_layout = Layout::from_size_align(mem::size_of::<T>(), mem::align_of::<T>()).unwrap();
+        let cookie = alloc::alloc(c_layout) as *mut T;
+        let data = Box::into_raw(Box::new(ProtocolData {
+            cookie,
+            user_data,
+
+            open_fn,
+            close_fn,
+            read_fn,
+            seek_fn,
+            size_fn,
+        }));
+
+        Protocol { name, data }
+    }
+
+    fn register(&self, ctx: *mut libmpv2_sys::mpv_handle) -> Result<()> {
+        let name = CString::new(&self.name[..])?;
+        unsafe {
+            mpv_err(
+                (),
+                libmpv2_sys::mpv_stream_cb_add_ro(
+                    ctx,
+                    name.as_ptr(),
+                    self.data as *mut _,
+                    Some(open_wrapper::<T, U>),
+                ),
+            )
+        }
+    }
+}
diff --git a/crates/libmpv2/src/mpv/render.rs b/crates/libmpv2/src/mpv/render.rs
new file mode 100644
index 0000000..91db34e
--- /dev/null
+++ b/crates/libmpv2/src/mpv/render.rs
@@ -0,0 +1,406 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use crate::{mpv::mpv_err, Error, Result};
+use std::collections::HashMap;
+use std::ffi::{c_void, CStr};
+use std::os::raw::c_int;
+use std::ptr;
+
+type DeleterFn = unsafe fn(*mut c_void);
+
+pub struct RenderContext {
+    ctx: *mut libmpv2_sys::mpv_render_context,
+    update_callback_cleanup: Option<Box<dyn FnOnce()>>,
+}
+
+/// For initializing the mpv OpenGL state via RenderParam::OpenGLInitParams
+pub struct OpenGLInitParams<GLContext> {
+    /// This retrieves OpenGL function pointers, and will use them in subsequent
+    /// operation.
+    /// Usually, you can simply call the GL context APIs from this callback (e.g.
+    /// glXGetProcAddressARB or wglGetProcAddress), but some APIs do not always
+    /// return pointers for all standard functions (even if present); in this
+    /// case you have to compensate by looking up these functions yourself when
+    /// libmpv wants to resolve them through this callback.
+    /// libmpv will not normally attempt to resolve GL functions on its own, nor
+    /// does it link to GL libraries directly.
+    pub get_proc_address: fn(ctx: &GLContext, name: &str) -> *mut c_void,
+
+    /// Value passed as ctx parameter to get_proc_address().
+    pub ctx: GLContext,
+}
+
+/// For RenderParam::FBO
+pub struct FBO {
+    pub fbo: i32,
+    pub width: i32,
+    pub height: i32,
+}
+
+#[repr(u32)]
+#[derive(Clone)]
+pub enum RenderFrameInfoFlag {
+    Present = libmpv2_sys::mpv_render_frame_info_flag_MPV_RENDER_FRAME_INFO_PRESENT,
+    Redraw = libmpv2_sys::mpv_render_frame_info_flag_MPV_RENDER_FRAME_INFO_REDRAW,
+    Repeat = libmpv2_sys::mpv_render_frame_info_flag_MPV_RENDER_FRAME_INFO_REPEAT,
+    BlockVSync = libmpv2_sys::mpv_render_frame_info_flag_MPV_RENDER_FRAME_INFO_BLOCK_VSYNC,
+}
+
+impl From<u64> for RenderFrameInfoFlag {
+    // mpv_render_frame_info_flag is u32, but mpv_render_frame_info.flags is u64 o\
+    fn from(val: u64) -> Self {
+        let val = val as u32;
+        match val {
+            libmpv2_sys::mpv_render_frame_info_flag_MPV_RENDER_FRAME_INFO_PRESENT => {
+                RenderFrameInfoFlag::Present
+            }
+            libmpv2_sys::mpv_render_frame_info_flag_MPV_RENDER_FRAME_INFO_REDRAW => {
+                RenderFrameInfoFlag::Redraw
+            }
+            libmpv2_sys::mpv_render_frame_info_flag_MPV_RENDER_FRAME_INFO_REPEAT => {
+                RenderFrameInfoFlag::Repeat
+            }
+            libmpv2_sys::mpv_render_frame_info_flag_MPV_RENDER_FRAME_INFO_BLOCK_VSYNC => {
+                RenderFrameInfoFlag::BlockVSync
+            }
+            _ => panic!("Tried converting invalid value to RenderFrameInfoFlag"),
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct RenderFrameInfo {
+    pub flags: RenderFrameInfoFlag,
+    pub target_time: i64,
+}
+
+pub enum RenderParamApiType {
+    OpenGl,
+}
+
+pub enum RenderParam<GLContext> {
+    Invalid,
+    ApiType(RenderParamApiType),
+    InitParams(OpenGLInitParams<GLContext>),
+    FBO(FBO),
+    FlipY(bool),
+    Depth(i32),
+    ICCProfile(Vec<u8>),
+    AmbientLight(i32),
+    X11Display(*const c_void),
+    WaylandDisplay(*const c_void),
+    AdvancedControl(bool),
+    NextFrameInfo(RenderFrameInfo),
+    BlockForTargetTime(bool),
+    SkipRendering(bool),
+}
+
+impl<C> From<&RenderParam<C>> for u32 {
+    fn from(val: &RenderParam<C>) -> Self {
+        match val {
+            RenderParam::Invalid => 0,
+            RenderParam::ApiType(_) => 1,
+            RenderParam::InitParams(_) => 2,
+            RenderParam::FBO(_) => 3,
+            RenderParam::FlipY(_) => 4,
+            RenderParam::Depth(_) => 5,
+            RenderParam::ICCProfile(_) => 6,
+            RenderParam::AmbientLight(_) => 7,
+            RenderParam::X11Display(_) => 8,
+            RenderParam::WaylandDisplay(_) => 9,
+            RenderParam::AdvancedControl(_) => 10,
+            RenderParam::NextFrameInfo(_) => 11,
+            RenderParam::BlockForTargetTime(_) => 12,
+            RenderParam::SkipRendering(_) => 13,
+        }
+    }
+}
+
+unsafe extern "C" fn gpa_wrapper<GLContext>(ctx: *mut c_void, name: *const i8) -> *mut c_void {
+    if ctx.is_null() {
+        panic!("ctx for get_proc_address wrapper is NULL");
+    }
+
+    let params: *mut OpenGLInitParams<GLContext> = ctx as _;
+    let params = &*params;
+    (params.get_proc_address)(
+        &params.ctx,
+        CStr::from_ptr(name)
+            .to_str()
+            .expect("Could not convert function name to str"),
+    )
+}
+
+unsafe extern "C" fn ru_wrapper<F: Fn() + Send + 'static>(ctx: *mut c_void) {
+    if ctx.is_null() {
+        panic!("ctx for render_update wrapper is NULL");
+    }
+
+    (*(ctx as *mut F))();
+}
+
+impl<C> From<OpenGLInitParams<C>> for libmpv2_sys::mpv_opengl_init_params {
+    fn from(val: OpenGLInitParams<C>) -> Self {
+        Self {
+            get_proc_address: Some(gpa_wrapper::<OpenGLInitParams<C>>),
+            get_proc_address_ctx: Box::into_raw(Box::new(val)) as *mut c_void,
+        }
+    }
+}
+
+impl<C> From<RenderParam<C>> for libmpv2_sys::mpv_render_param {
+    fn from(val: RenderParam<C>) -> Self {
+        let type_ = u32::from(&val);
+        let data = match val {
+            RenderParam::Invalid => ptr::null_mut(),
+            RenderParam::ApiType(api_type) => match api_type {
+                RenderParamApiType::OpenGl => {
+                    libmpv2_sys::MPV_RENDER_API_TYPE_OPENGL.as_ptr() as *mut c_void
+                }
+            },
+            RenderParam::InitParams(params) => {
+                Box::into_raw(Box::new(libmpv2_sys::mpv_opengl_init_params::from(params)))
+                    as *mut c_void
+            }
+            RenderParam::FBO(fbo) => Box::into_raw(Box::new(fbo)) as *mut c_void,
+            RenderParam::FlipY(flip) => Box::into_raw(Box::new(flip as c_int)) as *mut c_void,
+            RenderParam::Depth(depth) => Box::into_raw(Box::new(depth)) as *mut c_void,
+            RenderParam::ICCProfile(bytes) => {
+                Box::into_raw(bytes.into_boxed_slice()) as *mut c_void
+            }
+            RenderParam::AmbientLight(lux) => Box::into_raw(Box::new(lux)) as *mut c_void,
+            RenderParam::X11Display(ptr) => ptr as *mut _,
+            RenderParam::WaylandDisplay(ptr) => ptr as *mut _,
+            RenderParam::AdvancedControl(adv_ctrl) => {
+                Box::into_raw(Box::new(adv_ctrl as c_int)) as *mut c_void
+            }
+            RenderParam::NextFrameInfo(frame_info) => {
+                Box::into_raw(Box::new(frame_info)) as *mut c_void
+            }
+            RenderParam::BlockForTargetTime(block) => {
+                Box::into_raw(Box::new(block as c_int)) as *mut c_void
+            }
+            RenderParam::SkipRendering(skip_rendering) => {
+                Box::into_raw(Box::new(skip_rendering as c_int)) as *mut c_void
+            }
+        };
+        Self { type_, data }
+    }
+}
+
+unsafe fn free_void_data<T>(ptr: *mut c_void) {
+    drop(Box::<T>::from_raw(ptr as *mut T));
+}
+
+unsafe fn free_init_params<C>(ptr: *mut c_void) {
+    let params = Box::from_raw(ptr as *mut libmpv2_sys::mpv_opengl_init_params);
+    drop(Box::from_raw(
+        params.get_proc_address_ctx as *mut OpenGLInitParams<C>,
+    ));
+}
+
+impl RenderContext {
+    pub fn new<C>(
+        mpv: &mut libmpv2_sys::mpv_handle,
+        params: impl IntoIterator<Item = RenderParam<C>>,
+    ) -> Result<Self> {
+        let params: Vec<_> = params.into_iter().collect();
+        let mut raw_params: Vec<libmpv2_sys::mpv_render_param> = Vec::new();
+        raw_params.reserve(params.len() + 1);
+        let mut raw_ptrs: HashMap<*const c_void, DeleterFn> = HashMap::new();
+
+        for p in params {
+            // The render params are type-erased after they are passed to mpv. This is where we last
+            // know their real types, so we keep a deleter here.
+            let deleter: Option<DeleterFn> = match p {
+                RenderParam::InitParams(_) => Some(free_init_params::<C>),
+                RenderParam::FBO(_) => Some(free_void_data::<FBO>),
+                RenderParam::FlipY(_) => Some(free_void_data::<i32>),
+                RenderParam::Depth(_) => Some(free_void_data::<i32>),
+                RenderParam::ICCProfile(_) => Some(free_void_data::<Box<[u8]>>),
+                RenderParam::AmbientLight(_) => Some(free_void_data::<i32>),
+                RenderParam::NextFrameInfo(_) => Some(free_void_data::<RenderFrameInfo>),
+                _ => None,
+            };
+            let raw_param: libmpv2_sys::mpv_render_param = p.into();
+            if let Some(deleter) = deleter {
+                raw_ptrs.insert(raw_param.data, deleter);
+            }
+
+            raw_params.push(raw_param);
+        }
+        // the raw array must end with type = 0
+        raw_params.push(libmpv2_sys::mpv_render_param {
+            type_: 0,
+            data: ptr::null_mut(),
+        });
+
+        unsafe {
+            let raw_array =
+                Box::into_raw(raw_params.into_boxed_slice()) as *mut libmpv2_sys::mpv_render_param;
+            let ctx = Box::into_raw(Box::new(std::ptr::null_mut() as _));
+            let err = libmpv2_sys::mpv_render_context_create(ctx, &mut *mpv, raw_array);
+            drop(Box::from_raw(raw_array));
+            for (ptr, deleter) in raw_ptrs.iter() {
+                (deleter)(*ptr as _);
+            }
+
+            mpv_err(
+                Self {
+                    ctx: *Box::from_raw(ctx),
+                    update_callback_cleanup: None,
+                },
+                err,
+            )
+        }
+    }
+
+    pub fn set_parameter<C>(&self, param: RenderParam<C>) -> Result<()> {
+        unsafe {
+            mpv_err(
+                (),
+                libmpv2_sys::mpv_render_context_set_parameter(
+                    self.ctx,
+                    libmpv2_sys::mpv_render_param::from(param),
+                ),
+            )
+        }
+    }
+
+    pub fn get_info<C>(&self, param: RenderParam<C>) -> Result<RenderParam<C>> {
+        let is_next_frame_info = matches!(param, RenderParam::NextFrameInfo(_));
+        let raw_param = libmpv2_sys::mpv_render_param::from(param);
+        let res = unsafe { libmpv2_sys::mpv_render_context_get_info(self.ctx, raw_param) };
+        if res == 0 {
+            if !is_next_frame_info {
+                panic!("I don't know how to handle this info type.");
+            }
+            let raw_frame_info = raw_param.data as *mut libmpv2_sys::mpv_render_frame_info;
+            unsafe {
+                let raw_frame_info = *raw_frame_info;
+                return Ok(RenderParam::NextFrameInfo(RenderFrameInfo {
+                    flags: raw_frame_info.flags.into(),
+                    target_time: raw_frame_info.target_time,
+                }));
+            }
+        }
+        Err(Error::Raw(res))
+    }
+
+    /// Render video.
+    ///
+    /// Typically renders the video to a target surface provided via `fbo`
+    /// (the details depend on the backend in use). Options like "panscan" are
+    /// applied to determine which part of the video should be visible and how the
+    /// video should be scaled. You can change these options at runtime by using the
+    /// mpv property API.
+    ///
+    /// The renderer will reconfigure itself every time the target surface
+    /// configuration (such as size) is changed.
+    ///
+    /// This function implicitly pulls a video frame from the internal queue and
+    /// renders it. If no new frame is available, the previous frame is redrawn.
+    /// The update callback set with [set_update_callback](Self::set_update_callback)
+    /// notifies you when a new frame was added. The details potentially depend on
+    /// the backends and the provided parameters.
+    ///
+    /// Generally, libmpv will invoke your update callback some time before the video
+    /// frame should be shown, and then lets this function block until the supposed
+    /// display time. This will limit your rendering to video FPS. You can prevent
+    /// this by setting the "video-timing-offset" global option to 0. (This applies
+    /// only to "audio" video sync mode.)
+    ///
+    /// # Arguments
+    ///
+    /// * `fbo` - A framebuffer object to render to. In OpenGL, 0 is the current backbuffer
+    /// * `width` - The width of the framebuffer in pixels. This is used for scaling the
+    ///             video properly.
+    /// * `height` - The height of the framebuffer in pixels. This is used for scaling the
+    ///              video properly.
+    /// * `flip` - Whether to draw the image upside down. This is needed for OpenGL because
+    ///            it uses a coordinate system with positive Y up, but videos use positive
+    ///            Y down.
+    pub fn render<GLContext>(&self, fbo: i32, width: i32, height: i32, flip: bool) -> Result<()> {
+        let mut raw_params: Vec<libmpv2_sys::mpv_render_param> = Vec::with_capacity(3);
+        let mut raw_ptrs: HashMap<*const c_void, DeleterFn> = HashMap::new();
+
+        let raw_param: libmpv2_sys::mpv_render_param =
+            RenderParam::<GLContext>::FBO(FBO { fbo, width, height }).into();
+        raw_ptrs.insert(raw_param.data, free_void_data::<FBO>);
+        raw_params.push(raw_param);
+        let raw_param: libmpv2_sys::mpv_render_param = RenderParam::<GLContext>::FlipY(flip).into();
+        raw_ptrs.insert(raw_param.data, free_void_data::<i32>);
+        raw_params.push(raw_param);
+        // the raw array must end with type = 0
+        raw_params.push(libmpv2_sys::mpv_render_param {
+            type_: 0,
+            data: ptr::null_mut(),
+        });
+
+        let raw_array =
+            Box::into_raw(raw_params.into_boxed_slice()) as *mut libmpv2_sys::mpv_render_param;
+
+        let ret = unsafe {
+            mpv_err(
+                (),
+                libmpv2_sys::mpv_render_context_render(self.ctx, raw_array),
+            )
+        };
+        unsafe {
+            drop(Box::from_raw(raw_array));
+        }
+
+        unsafe {
+            for (ptr, deleter) in raw_ptrs.iter() {
+                (deleter)(*ptr as _);
+            }
+        }
+
+        ret
+    }
+
+    /// Set the callback that notifies you when a new video frame is available, or if the video display
+    /// configuration somehow changed and requires a redraw. Similar to [EventContext::set_wakeup_callback](crate::events::EventContext::set_wakeup_callback), you
+    /// must not call any mpv API from the callback, and all the other listed restrictions apply (such
+    /// as not exiting the callback by throwing exceptions).
+    ///
+    /// This can be called from any thread, except from an update callback. In case of the OpenGL backend,
+    /// no OpenGL state or API is accessed.
+    ///
+    /// Calling this will raise an update callback immediately.
+    pub fn set_update_callback<F: Fn() + Send + 'static>(&mut self, callback: F) {
+        if let Some(update_callback_cleanup) = self.update_callback_cleanup.take() {
+            update_callback_cleanup();
+        }
+        let raw_callback = Box::into_raw(Box::new(callback));
+        self.update_callback_cleanup = Some(Box::new(move || unsafe {
+            drop(Box::from_raw(raw_callback));
+        }) as Box<dyn FnOnce()>);
+        unsafe {
+            libmpv2_sys::mpv_render_context_set_update_callback(
+                self.ctx,
+                Some(ru_wrapper::<F>),
+                raw_callback as *mut c_void,
+            );
+        }
+    }
+}
+
+impl Drop for RenderContext {
+    fn drop(&mut self) {
+        if let Some(update_callback_cleanup) = self.update_callback_cleanup.take() {
+            update_callback_cleanup();
+        }
+        unsafe {
+            libmpv2_sys::mpv_render_context_free(self.ctx);
+        }
+    }
+}
diff --git a/crates/libmpv2/src/tests.rs b/crates/libmpv2/src/tests.rs
new file mode 100644
index 0000000..1e7635d
--- /dev/null
+++ b/crates/libmpv2/src/tests.rs
@@ -0,0 +1,222 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use crate::events::{Event, EventContext, PropertyData};
+use crate::mpv_node::MpvNode;
+use crate::*;
+
+use std::collections::HashMap;
+use std::thread;
+use std::time::Duration;
+
+#[test]
+fn initializer() {
+    let mpv = Mpv::with_initializer(|init| {
+        init.set_property("osc", true)?;
+        init.set_property("input-default-bindings", true)?;
+        init.set_property("volume", 30)?;
+
+        Ok(())
+    })
+    .unwrap();
+
+    assert_eq!(true, mpv.get_property("osc").unwrap());
+    assert_eq!(true, mpv.get_property("input-default-bindings").unwrap());
+    assert_eq!(30i64, mpv.get_property("volume").unwrap());
+}
+
+#[test]
+fn properties() {
+    let mpv = Mpv::new().unwrap();
+    mpv.set_property("volume", 0).unwrap();
+    mpv.set_property("vo", "null").unwrap();
+    mpv.set_property("ytdl-format", "best[width<240]").unwrap();
+    mpv.set_property("sub-gauss", 0.6).unwrap();
+
+    assert_eq!(0i64, mpv.get_property("volume").unwrap());
+    let vo: MpvStr = mpv.get_property("vo").unwrap();
+    assert_eq!("null", &*vo);
+    assert_eq!(true, mpv.get_property("ytdl").unwrap());
+    let subg: f64 = mpv.get_property("sub-gauss").unwrap();
+    assert_eq!(
+        0.6,
+        f64::round(subg * f64::powi(10.0, 4)) / f64::powi(10.0, 4)
+    );
+    mpv.command(
+        "loadfile",
+        &["test-data/speech_12kbps_mb.wav", "append-play"],
+    )
+    .unwrap();
+    thread::sleep(Duration::from_millis(250));
+
+    let title: MpvStr = mpv.get_property("media-title").unwrap();
+    assert_eq!(&*title, "speech_12kbps_mb.wav");
+}
+
+macro_rules! assert_event_occurs {
+    ($ctx:ident, $timeout:literal, $( $expected:pat),+) => {
+        loop {
+            match $ctx.wait_event($timeout) {
+                $( Some($expected) )|+ => {
+                    break;
+                },
+                None => {
+                    continue
+                },
+                other => panic!("Event did not occur, got: {:?}", other),
+            }
+        }
+    }
+}
+
+#[test]
+fn events() {
+    let mpv = Mpv::new().unwrap();
+    let mut ev_ctx = EventContext::new(mpv.ctx);
+    ev_ctx.disable_deprecated_events().unwrap();
+
+    ev_ctx.observe_property("volume", Format::Int64, 0).unwrap();
+    ev_ctx
+        .observe_property("media-title", Format::String, 1)
+        .unwrap();
+
+    mpv.set_property("vo", "null").unwrap();
+
+    // speed up playback so test finishes faster
+    mpv.set_property("speed", 100).unwrap();
+
+    assert_event_occurs!(
+        ev_ctx,
+        3.,
+        Ok(Event::PropertyChange {
+            name: "volume",
+            change: PropertyData::Int64(100),
+            reply_userdata: 0,
+        })
+    );
+
+    mpv.set_property("volume", 0).unwrap();
+    assert_event_occurs!(
+        ev_ctx,
+        10.,
+        Ok(Event::PropertyChange {
+            name: "volume",
+            change: PropertyData::Int64(0),
+            reply_userdata: 0,
+        })
+    );
+    assert!(ev_ctx.wait_event(3.).is_none());
+    mpv.command("loadfile", &["test-data/jellyfish.mp4", "append-play"])
+        .unwrap();
+    assert_event_occurs!(ev_ctx, 10., Ok(Event::StartFile));
+    assert_event_occurs!(
+        ev_ctx,
+        10.,
+        Ok(Event::PropertyChange {
+            name: "media-title",
+            change: PropertyData::Str("jellyfish.mp4"),
+            reply_userdata: 1,
+        })
+    );
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::FileLoaded));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::VideoReconfig));
+
+    mpv.command("loadfile", &["test-data/speech_12kbps_mb.wav", "replace"])
+        .unwrap();
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::VideoReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::VideoReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::EndFile(mpv_end_file_reason::Stop)));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::StartFile));
+    assert_event_occurs!(
+        ev_ctx,
+        3.,
+        Ok(Event::PropertyChange {
+            name: "media-title",
+            change: PropertyData::Str("speech_12kbps_mb.wav"),
+            reply_userdata: 1,
+        })
+    );
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::VideoReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::FileLoaded));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::PlaybackRestart));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert_event_occurs!(ev_ctx, 10., Ok(Event::EndFile(mpv_end_file_reason::Eof)));
+    assert_event_occurs!(ev_ctx, 3., Ok(Event::AudioReconfig));
+    assert!(ev_ctx.wait_event(3.).is_none());
+}
+
+#[test]
+fn node_map() -> Result<()> {
+    let mpv = Mpv::new()?;
+
+    mpv.command(
+        "loadfile",
+        &["test-data/speech_12kbps_mb.wav", "append-play"],
+    )
+    .unwrap();
+
+    thread::sleep(Duration::from_millis(250));
+    let audio_params = mpv.get_property::<MpvNode>("audio-params")?;
+    let params = audio_params.map().unwrap().collect::<HashMap<_, _>>();
+
+    assert_eq!(params.len(), 5);
+
+    let format = params.get("format").unwrap();
+    assert_eq!(format, &MpvNode::String("s16".to_string()));
+
+    let samplerate = params.get("samplerate").unwrap();
+    assert_eq!(samplerate, &MpvNode::Int64(48_000));
+
+    let channels = params.get("channels").unwrap();
+    assert_eq!(channels, &MpvNode::String("mono".to_string()));
+
+    let hr_channels = params.get("hr-channels").unwrap();
+    assert_eq!(hr_channels, &MpvNode::String("mono".to_string()));
+
+    let channel_count = params.get("channel-count").unwrap();
+    assert_eq!(channel_count, &MpvNode::Int64(1));
+
+    Ok(())
+}
+
+#[test]
+fn node_array() -> Result<()> {
+    let mpv = Mpv::new()?;
+
+    mpv.command(
+        "loadfile",
+        &["test-data/speech_12kbps_mb.wav", "append-play"],
+    )
+    .unwrap();
+
+    thread::sleep(Duration::from_millis(250));
+    let playlist = mpv.get_property::<MpvNode>("playlist")?;
+    let items = playlist.array().unwrap().collect::<Vec<_>>();
+
+    assert_eq!(items.len(), 1);
+    let track = items[0].clone().map().unwrap().collect::<HashMap<_, _>>();
+
+    let filename = track.get("filename").unwrap();
+
+    assert_eq!(
+        filename,
+        &MpvNode::String("test-data/speech_12kbps_mb.wav".to_string())
+    );
+
+    Ok(())
+}