""" 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._scrub_offset = 0.0 # global offset of the loaded scrub source self._scrub_active = False # True when scrub source is loaded 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 set_scrub_source(self, proxy_path, global_offset=0.0): """Load a proxy file for frame-accurate scrubbing.""" self._recording_path = proxy_path self._scrub_offset = global_offset self._scrub_active = True if self._review_player: self._review_player.load_at(proxy_path, 0, pause=True, hr_seek=True) self._stack.set_visible_child_name("review") log.info("Scrub source: %s (offset %.1fs)", proxy_path, global_offset) def scrub_to(self, seconds): """Seek the review player to an exact frame (for scrub bar dragging).""" if self._review_player: self._review_player.show_frame_at(seconds) 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 reset(self): """Reset for session transition — keep players alive, just unload content.""" log.info("Resetting monitor") self._live_source_url = None self._recording_path = None self._live_loaded = False self._scrub_active = False self._scrub_offset = 0.0 if self._live_player: self._live_player.command("stop") if self._review_player: self._review_player.command("stop") self._stack.set_visible_child_name("live") def stop(self): """Full teardown — terminates mpv players. Only call on app exit.""" 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: self._scrub_active = False # 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 / review mode if self._scrub_active: # Scrub mode: driven directly by scrub_to(), not by timeline if current != "review": self._stack.set_visible_child_name("review") return elif current == "live": # Transitioning from live: load MKV at cursor position atomically pos = s.cursor if self._review_player and self._recording_path: self._review_player.load_at(self._recording_path, pos, pause=s.paused) if not s.paused: self._review_player.play() self._stack.set_visible_child_name("review") else: # Already in review (non-scrub): seek if cursor moved if self._review_player: player_pos = self._review_player.time_pos or 0 if abs(s.cursor - player_pos) > 1.0: self._review_player.seek(s.cursor) if s.paused: self._review_player.pause() else: self._review_player.play() def _sync_cursor_from_player(self): s = self._timeline.state if self._scrub_active: # Scrub mode: don't sync cursor from player — scrub bar drives cursor return True 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() return True