some changes

This commit is contained in:
2026-04-01 16:26:25 -03:00
parent bdc5705022
commit 68802db15c
10 changed files with 500 additions and 567 deletions

View File

@@ -1,97 +1,193 @@
"""
MonitorWidget: mpv-based live stream monitor embedded in GTK4.
MonitorWidget: mpv-based stream monitor embedded in GTK4 via OpenGL.
Uses libmpv's OpenGL render API + Gtk.GLArea for proper embedding.
No X11 wid hacks — renders directly to a GL texture in the GTK layout.
Works on both X11 and Wayland.
Supports DVR-style playback of a growing recording file:
- Follows live edge by default
- Slider scrubs video + audio together
- Can capture frame at current cursor position
"""
import ctypes
import logging
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib, Gdk
from gi.repository import Gtk, GLib
from cht.ui.mpv import Player
log = logging.getLogger(__name__)
# Cache libGL reference
_libGL = ctypes.cdll.LoadLibrary("libGL.so.1")
GL_DRAW_FRAMEBUFFER_BINDING = 0x8CA6
class MonitorWidget(Gtk.Box):
"""Widget that embeds mpv video via OpenGL into the GTK4 layout."""
"""Embedded mpv video player with DVR controls."""
def __init__(self, **kwargs):
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
self._player = None
self._following_live = True
self._slider_updating = False
# GL area for video
self._gl_area = Gtk.GLArea()
self._gl_area.set_hexpand(True)
self._gl_area.set_vexpand(True)
self._gl_area.set_auto_render(False)
self._gl_area.set_has_depth_buffer(False)
self._gl_area.set_has_stencil_buffer(False)
self._gl_area.connect("realize", self._on_realize)
self._gl_area.connect("unrealize", self._on_unrealize)
self._gl_area.connect("render", self._on_render)
self.append(self._gl_area)
log.info("MonitorWidget initialized (GLArea)")
# Slider for scrubbing (shared timeline for video + audio)
slider_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
slider_box.set_margin_start(4)
slider_box.set_margin_end(4)
slider_box.set_margin_bottom(2)
self._time_label = Gtk.Label(label="00:00")
self._time_label.set_width_chars(6)
slider_box.append(self._time_label)
self._slider = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL)
self._slider.set_hexpand(True)
self._slider.set_range(0, 1)
self._slider.set_draw_value(False)
self._slider.connect("value-changed", self._on_slider_changed)
slider_box.append(self._slider)
self._duration_label = Gtk.Label(label="00:00")
self._duration_label.set_width_chars(6)
slider_box.append(self._duration_label)
self._live_btn = Gtk.Button(label="LIVE")
self._live_btn.add_css_class("suggested-action")
self._live_btn.connect("clicked", self._on_live_clicked)
slider_box.append(self._live_btn)
self.append(slider_box)
# Update slider position periodically
GLib.timeout_add(500, self._update_slider)
log.info("MonitorWidget initialized (GLArea + slider)")
# -- GL callbacks --
def _on_realize(self, gl_area):
"""GL context is ready — initialize mpv's render context."""
log.info("GLArea realized")
gl_area.make_current()
if gl_area.get_error():
log.error("GLArea error: %s", gl_area.get_error())
return
def _on_unrealize(self, gl_area):
"""Clean up mpv render context."""
log.info("GLArea unrealized")
if self._player:
self._player.terminate()
self._player = None
self.stop()
def _on_render(self, gl_area, gl_context):
"""Render mpv's current frame to the GLArea."""
if not self._player:
return True
# Get the default FBO that GTK4 GLArea renders to
fbo = gl_area.get_buffer() if hasattr(gl_area, 'get_buffer') else 0
width = gl_area.get_width()
height = gl_area.get_height()
# GTK4 GLArea uses its own FBO, get it from GL state
import ctypes
fbo_id = ctypes.c_int(0)
gl = ctypes.cdll.LoadLibrary("libGL.so.1")
gl.glGetIntegerv(0x8CA6, ctypes.byref(fbo_id)) # GL_DRAW_FRAMEBUFFER_BINDING
_libGL.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, ctypes.byref(fbo_id))
self._player.render(fbo_id.value, width, height)
return True
def _on_mpv_update(self):
"""Called by mpv when a new frame is ready. Triggers re-render."""
GLib.idle_add(self._gl_area.queue_render)
def start_stream(self, source, record_path=None):
"""Start playing from a URL and optionally record to disk.
# -- Slider --
def _on_slider_changed(self, slider):
if self._slider_updating or not self._player:
return
pos = slider.get_value()
self._player.seek(pos)
self._following_live = False
self._live_btn.remove_css_class("suggested-action")
def _on_live_clicked(self, button):
if self._player and self._player.duration:
self._player.seek(self._player.duration - 0.5)
self._following_live = True
self._live_btn.add_css_class("suggested-action")
def _update_slider(self):
if not self._player:
return True
pos = self._player.time_pos
dur = self._player.duration
if pos is not None and dur is not None and dur > 0:
self._slider_updating = True
self._slider.set_range(0, dur)
self._slider.set_value(pos)
self._slider_updating = False
self._time_label.set_text(self._fmt_time(pos))
self._duration_label.set_text(self._fmt_time(dur))
# Auto-follow live edge: if at EOF or falling behind, reload
if self._following_live:
if self._player.idle or dur - pos > 3:
self._reload_live()
return True # keep timer running
def _reload_live(self):
"""Reload the growing file and seek to near-end (live edge)."""
if not self._player or not self._recording_path:
return
self._player.play(str(self._recording_path))
# Small delay then seek to end
GLib.timeout_add(500, self._seek_to_end_once)
@staticmethod
def _fmt_time(seconds):
m, s = divmod(int(seconds), 60)
h, m = divmod(m, 60)
if h:
return f"{h}:{m:02d}:{s:02d}"
return f"{m:02d}:{s:02d}"
# -- Public API --
def _seek_to_end_once(self):
if self._player and self._player.duration:
self._player.seek(self._player.duration - 0.5)
return False # don't repeat
def start_recording(self, recording_path):
"""Start DVR-style playback of a growing recording file.
Args:
source: TCP URL (tcp://...), file path, etc.
record_path: if set, mpv dumps the raw stream to this file
recording_path: path to the .ts file being written by ffmpeg
"""
self._recording_path = recording_path
self._gl_area.make_current()
self._player = Player(record_path=record_path)
self._player = Player()
self._player.init_gl(update_callback=self._on_mpv_update)
self._player.play(source)
log.info("Monitor streaming from: %s (record=%s)", source, record_path)
self._player.play_file(recording_path)
self._following_live = True
self._live_btn.add_css_class("suggested-action")
log.info("Monitor playing recording: %s", recording_path)
def screenshot(self, path):
"""Capture frame at current cursor position."""
if self._player:
self._player.screenshot(path)
def stop(self):
"""Stop playback and release mpv."""
if self._player:
log.info("Stopping monitor")
self._player.terminate()

View File

@@ -2,7 +2,10 @@
MPV wrapper using python-mpv (libmpv bindings) with OpenGL render API.
Renders video frames to an OpenGL context provided by GTK4's GLArea.
No subprocess calls, no X11 wid hacks.
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
@@ -30,40 +33,50 @@ _get_proc_address = _make_get_proc_address()
class Player:
"""Wraps a libmpv player with OpenGL render context for GTK4 embedding."""
"""Wraps a libmpv player with OpenGL render context for GTK4 embedding.
def __init__(self, record_path=None):
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,
"profile": "low-latency",
"cache": "no",
"untimed": True,
"demuxer_thread": "no",
"demuxer_lavf_o": "fflags=+nobuffer",
"video_sync": "display-desync",
"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,
}
if record_path:
opts["stream_record"] = str(record_path)
log.info("mpv will record stream to: %s", record_path)
log.info("Creating mpv player (OpenGL render mode)")
self._player = libmpv.MPV(**opts)
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 from a thread with an active GL context (e.g. GLArea realize).
Args:
update_callback: called by mpv when a new frame is ready to render.
Should trigger a GLArea queue_render().
Must be called with an active GL context.
"""
self._update_callback = update_callback
self._ctx = libmpv.MpvRenderContext(
@@ -73,41 +86,40 @@ class Player:
"get_proc_address": _get_proc_address,
},
)
# Keep reference to prevent GC of the ctypes callback
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):
"""Called by mpv from any thread when a new frame is available."""
if self._update_callback:
self._update_callback()
def render(self, fbo, width, height):
"""Render the current frame to the given OpenGL FBO.
Call from the GLArea render signal handler.
"""
"""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,
},
opengl_fbo={"fbo": fbo, "w": width, "h": height},
)
def play(self, source):
"""Play from a file path or URL."""
"""Play from any source (URL, file path)."""
log.info("mpv play: %s", source)
self._player.play(str(source))
def play_fd(self, fd):
"""Play from a raw file descriptor."""
source = f"fd://{fd}"
log.info("mpv play from fd: %s", source)
self._player.play(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
@@ -120,13 +132,18 @@ class Player:
return self._player.pause
def seek(self, seconds):
"""Seek to absolute position in 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()