some changes
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user