""" MPV wrapper using python-mpv (libmpv bindings) with OpenGL render API. Renders video frames to an OpenGL context provided by GTK4's GLArea. Supports DVR-style playback of a growing recording file: - Follow live edge (default) - Scrub back to any point - Audio + video synced via single slider """ import ctypes import logging import mpv as libmpv log = logging.getLogger(__name__) def _make_get_proc_address(): """Create a ctypes callback for OpenGL function loading.""" libgl = ctypes.cdll.LoadLibrary("libGL.so.1") libgl.glXGetProcAddressARB.restype = ctypes.c_void_p libgl.glXGetProcAddressARB.argtypes = [ctypes.c_char_p] @ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p) def get_proc_address(_, name): return libgl.glXGetProcAddressARB(name) return get_proc_address _get_proc_address = _make_get_proc_address() class Player: """Wraps a libmpv player with OpenGL render context for GTK4 embedding. Designed for DVR-style playback of a growing file: - play_file() opens the recording and seeks to end (live edge) - seek() scrubs to any position (audio + video move together) - time_pos / duration track playback state for the slider """ def __init__(self): opts = { "input_default_bindings": False, "input_vo_keyboard": False, "osc": False, "vo": "libmpv", "hwdec": "auto", "video_sync": "display-desync", # DVR: keep alive at EOF, wait for more data "keep_open": "yes", "demuxer_max_bytes": "500MiB", "demuxer_readahead_secs": "5", # Allow re-reading growing file "demuxer_cache_wait": True, } log.info("Creating mpv player (OpenGL render, DVR mode)") self._player = libmpv.MPV(log_handler=self._mpv_log, loglevel="v", **opts) self._ctx = None self._update_callback = None log.info("mpv player created") @staticmethod def _mpv_log(loglevel, component, message): msg = f"[mpv/{component}] {message.strip()}" if loglevel in ("fatal", "error"): log.error(msg) elif loglevel == "warn": log.warning(msg) else: log.debug(msg) def init_gl(self, update_callback): """Initialize the OpenGL render context. Must be called with an active GL context. """ self._update_callback = update_callback self._ctx = libmpv.MpvRenderContext( self._player, "opengl", opengl_init_params={ "get_proc_address": _get_proc_address, }, ) self._get_proc_address_ref = _get_proc_address self._ctx.update_cb = self._on_mpv_update log.info("mpv OpenGL render context initialized") def _on_mpv_update(self): if self._update_callback: self._update_callback() def render(self, fbo, width, height): """Render current frame to the given OpenGL FBO.""" if self._ctx: self._ctx.render( flip_y=True, opengl_fbo={"fbo": fbo, "w": width, "h": height}, ) def play(self, source): """Play from any source (URL, file path).""" log.info("mpv play: %s", source) self._player.play(str(source)) def play_file(self, path): """Play a recording file, seeking to the end (live edge).""" log.info("mpv play_file (DVR): %s", path) self._player.play(str(path)) # Seek to end once playback starts self._player.observe_property("duration", self._seek_to_live_once) def _seek_to_live_once(self, name, value): """Seek to live edge once duration is known, then stop observing.""" if value and value > 1: log.info("Seeking to live edge: %.1fs", value) self._player.seek(value - 0.5, reference="absolute") self._player.unobserve_property("duration", self._seek_to_live_once) def pause(self): self._player.pause = True def resume(self): self._player.pause = False @property def paused(self): return self._player.pause def seek(self, seconds): """Seek to absolute position. Audio + video move together.""" self._player.seek(seconds, reference="absolute") def seek_relative(self, seconds): """Seek relative to current position.""" self._player.seek(seconds, reference="relative") def screenshot(self, path): """Save current frame as an image file.""" self._player.screenshot_to_file(str(path), includes="video") log.debug("Screenshot saved: %s", path) def stop(self): log.info("mpv stop") self._player.stop() def terminate(self): log.info("mpv terminate") try: if self._ctx: self._ctx.free() self._ctx = None self._player.terminate() except Exception as e: log.warning("mpv terminate error: %s", e) @property def idle(self): return self._player.core_idle @property def duration(self): try: return self._player.duration except Exception: return None @property def time_pos(self): try: return self._player.time_pos except Exception: return None