Files
mitus/cht/ui/monitor.py
2026-04-10 11:47:15 -03:00

245 lines
8.8 KiB
Python

"""
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, priority=GLib.PRIORITY_HIGH)
)
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 or not self._live_loaded:
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, priority=GLib.PRIORITY_HIGH)
)
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 to scrub: just switch stack.
# Don't auto-load the growing MKV — user picks a segment via scrub bar.
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