178 lines
5.4 KiB
Python
178 lines
5.4 KiB
Python
"""
|
|
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
|