working the player

This commit is contained in:
2026-04-01 19:23:17 -03:00
parent 68802db15c
commit 0f7e4424bc
13 changed files with 1013 additions and 571 deletions

View File

@@ -1,10 +1,13 @@
"""
MonitorWidget: mpv-based stream monitor embedded in GTK4 via OpenGL.
MonitorWidget: dual-player video display 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
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
@@ -18,179 +21,187 @@ 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
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):
"""Embedded mpv video player with DVR controls."""
"""Dual-player mpv display, driven by Timeline "changed" signal."""
def __init__(self, **kwargs):
def __init__(self, timeline, **kwargs):
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
self._player = None
self._following_live = True
self._slider_updating = False
self._timeline = timeline
self._live_source_url = None
self._recording_path = None
# 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)
self._live_player = None
self._live_loaded = False
# 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._review_player = None
self._time_label = Gtk.Label(label="00:00")
self._time_label.set_width_chars(6)
slider_box.append(self._time_label)
self._stack = Gtk.Stack()
self._stack.set_hexpand(True)
self._stack.set_vexpand(True)
self._stack.set_transition_type(Gtk.StackTransitionType.NONE)
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._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._duration_label = Gtk.Label(label="00:00")
self._duration_label.set_width_chars(6)
slider_box.append(self._duration_label)
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._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(self._stack)
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}"
timeline.connect("changed", self._on_changed)
GLib.timeout_add(500, self._sync_cursor_from_player)
log.info("MonitorWidget initialized")
# -- 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 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 start_recording(self, recording_path):
"""Start DVR-style playback of a growing recording file.
def set_recording(self, path):
self._recording_path = path
log.info("Recording path: %s", path)
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 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):
"""Capture frame at current cursor position."""
if self._player:
self._player.screenshot(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):
if self._player:
log.info("Stopping monitor")
self._player.terminate()
self._player = None
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:
log.info("Monitor already stopped")
# 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