""" MPV wrapper using python-mpv (libmpv bindings) with OpenGL render API. Renders video frames to an OpenGL context provided by GTK4's GLArea. Driven by the Timeline state machine — does not manage its own state. """ 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. Does not manage playback state — that's the Timeline's job. Provides: load, play, pause, seek, show_frame_at, render. """ def __init__(self): opts = { "input_default_bindings": False, "input_vo_keyboard": False, "osc": False, "vo": "libmpv", "hwdec": "auto", # Keep open at EOF so LIVE mode can wait for more data "keep_open": "yes", "keep_open_pause": "no", } self._player = libmpv.MPV(log_handler=self._mpv_log, loglevel="warn", **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) def init_gl(self, update_callback): """Initialize the OpenGL render context. Call with 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 GL 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 load(self, path): """Load a recording file. Does not start playback.""" log.info("mpv load: %s", path) self._player.loadfile(str(path), mode="replace") def load_at(self, path, seconds, pause=True): """Load a file and seek to position atomically. Avoids async seek race.""" log.info("mpv load_at: %s at %.1fs pause=%s", path, seconds, pause) self._player["pause"] = pause self._player.loadfile(str(path), mode="replace", start=str(seconds)) def load_live(self, url): """Load a live stream URL with low-latency options.""" self._player["cache"] = "no" self._player["demuxer-max-bytes"] = "512KiB" self._player["audio-buffer"] = 0.2 log.info("mpv load_live: %s", url) self._player.loadfile(str(url), mode="replace") def command(self, *args): """Send a command to mpv.""" try: self._player.command(*args) except Exception: pass def play(self): """Resume/start playback.""" self._player.pause = False def pause(self): """Pause playback.""" self._player.pause = True def seek(self, seconds): """Seek to absolute position.""" try: self._player.seek(seconds, reference="absolute") except Exception: pass # seek may fail if file not loaded yet def show_frame_at(self, seconds): """Pause and show the frame at the given timestamp.""" self._player.pause = True try: self._player.seek(seconds, reference="absolute") except Exception: pass @property def time_pos(self): try: return self._player.time_pos except Exception: return None @property def paused(self): return self._player.pause @property def idle(self): return self._player.core_idle def screenshot(self, path): """Save current frame as an image file.""" try: self._player.screenshot_to_file(str(path), includes="video") except Exception as e: log.warning("Screenshot failed: %s", e) 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)