""" MonitorWidget: dual-player video display embedded in GTK4 via OpenGL. Two players share the same position via a Gtk.Stack: - "live" player: mpv reads UDP relay (low latency, always streaming) - "review" player: mpv reads local MKV file (full seek support) Driven by a single Timeline "changed" signal. Reads timeline.state directly: state.live=True → show live stack, live player streams state.live=False → show review stack, apply state.paused """ 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__) _libGL = ctypes.cdll.LoadLibrary("libGL.so.1") GL_DRAW_FRAMEBUFFER_BINDING = 0x8CA6 def _make_gl_area(on_realize, on_unrealize, on_render): gl_area = Gtk.GLArea() gl_area.set_hexpand(True) gl_area.set_vexpand(True) gl_area.set_auto_render(False) gl_area.set_has_depth_buffer(False) gl_area.set_has_stencil_buffer(False) gl_area.connect("realize", on_realize) gl_area.connect("unrealize", on_unrealize) gl_area.connect("render", on_render) return gl_area class MonitorWidget(Gtk.Box): """Dual-player mpv display, driven by Timeline "changed" signal.""" def __init__(self, timeline, **kwargs): super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) self._timeline = timeline self._live_source_url = None self._recording_path = None self._live_player = None self._live_loaded = False self._review_player = None self._stack = Gtk.Stack() self._stack.set_hexpand(True) self._stack.set_vexpand(True) self._stack.set_transition_type(Gtk.StackTransitionType.NONE) self._live_gl = _make_gl_area( self._on_live_realize, self._on_live_unrealize, self._on_live_render, ) self._stack.add_named(self._live_gl, "live") self._review_gl = _make_gl_area( self._on_review_realize, self._on_review_unrealize, self._on_review_render, ) self._stack.add_named(self._review_gl, "review") self.append(self._stack) timeline.connect("changed", self._on_changed) GLib.timeout_add(500, self._sync_cursor_from_player) log.info("MonitorWidget initialized") # -- Public API -- def set_live_source(self, url): self._live_source_url = url log.info("Live source: %s", url) if self._live_player and not self._live_loaded: self._live_player.load_live(url) self._live_player.play() self._live_loaded = True def set_recording(self, path): self._recording_path = path log.info("Recording path: %s", path) def get_live_position(self): """Return the live player's current time_pos, or None.""" if self._live_player: return self._live_player.time_pos return None def screenshot(self, path): if self._timeline.state.live and self._live_player: self._live_player.screenshot(path) elif self._review_player: self._review_player.screenshot(path) def stop(self): log.info("Stopping monitor") if self._live_player: self._live_player.terminate() self._live_player = None self._live_loaded = False if self._review_player: self._review_player.terminate() self._review_player = None # -- Live GLArea -- def _on_live_realize(self, gl_area): gl_area.make_current() self._live_player = Player() self._live_player.init_gl( update_callback=lambda: GLib.idle_add(self._live_gl.queue_render) ) log.info("Live player created") if self._live_source_url and not self._live_loaded: self._live_player.load_live(self._live_source_url) self._live_player.play() self._live_loaded = True def _on_live_unrealize(self, gl_area): if self._live_player: self._live_player.terminate() self._live_player = None self._live_loaded = False def _on_live_render(self, gl_area, _ctx): if not self._live_player: return True fbo = ctypes.c_int(0) _libGL.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, ctypes.byref(fbo)) self._live_player.render(fbo.value, gl_area.get_width(), gl_area.get_height()) return True # -- Review GLArea -- def _on_review_realize(self, gl_area): gl_area.make_current() self._review_player = Player() self._review_player.init_gl( update_callback=lambda: GLib.idle_add(self._review_gl.queue_render) ) log.info("Review player created") def _on_review_unrealize(self, gl_area): if self._review_player: self._review_player.terminate() self._review_player = None def _on_review_render(self, gl_area, _ctx): if not self._review_player: return True fbo = ctypes.c_int(0) _libGL.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, ctypes.byref(fbo)) self._review_player.render(fbo.value, gl_area.get_width(), gl_area.get_height()) return True # -- Timeline response -- def _on_changed(self, timeline): s = timeline.state current = self._stack.get_visible_child_name() if s.live: # Ensure live player is loaded and playing if self._live_player and not self._live_loaded and self._live_source_url: self._live_player.load_live(self._live_source_url) self._live_player.play() self._live_loaded = True elif self._live_player and self._live_loaded: self._live_player.play() if current != "live": self._stack.set_visible_child_name("live") else: # Scrub mode if current == "live": # Transitioning from live: seek review player to live position pos = s.cursor # already set by toggle_live() if self._review_player and self._recording_path: self._review_player.load(self._recording_path) if s.paused: self._review_player.show_frame_at(pos) else: self._review_player.seek(pos) self._review_player.play() self._stack.set_visible_child_name("review") else: # Already in review: just apply paused state if self._review_player: if s.paused: self._review_player.pause() else: self._review_player.play() def _sync_cursor_from_player(self): s = self._timeline.state if not s.live and not s.paused and self._review_player: pos = self._review_player.time_pos if pos is not None and pos > 0: self._timeline.set_cursor(pos) # Live mode: cursor driven by tick_live() in window.py return True