""" MonitorWidget: mpv-based stream monitor embedded in GTK4 via OpenGL. 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 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): """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) # 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): log.info("GLArea realized") gl_area.make_current() if gl_area.get_error(): log.error("GLArea error: %s", gl_area.get_error()) def _on_unrealize(self, gl_area): log.info("GLArea unrealized") self.stop() def _on_render(self, gl_area, gl_context): if not self._player: return True width = gl_area.get_width() height = gl_area.get_height() fbo_id = ctypes.c_int(0) _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): GLib.idle_add(self._gl_area.queue_render) # -- 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: recording_path: path to the .ts file being written by ffmpeg """ self._recording_path = recording_path self._gl_area.make_current() self._player = Player() self._player.init_gl(update_callback=self._on_mpv_update) 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): if self._player: log.info("Stopping monitor") self._player.terminate() self._player = None else: log.info("Monitor already stopped")