working the player
This commit is contained in:
@@ -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
|
||||
|
||||
123
cht/ui/mpv.py
123
cht/ui/mpv.py
@@ -2,10 +2,7 @@
|
||||
MPV wrapper using python-mpv (libmpv bindings) with OpenGL render API.
|
||||
|
||||
Renders video frames to an OpenGL context provided by GTK4's GLArea.
|
||||
Supports DVR-style playback of a growing recording file:
|
||||
- Follow live edge (default)
|
||||
- Scrub back to any point
|
||||
- Audio + video synced via single slider
|
||||
Driven by the Timeline state machine — does not manage its own state.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
@@ -35,10 +32,8 @@ _get_proc_address = _make_get_proc_address()
|
||||
class Player:
|
||||
"""Wraps a libmpv player with OpenGL render context for GTK4 embedding.
|
||||
|
||||
Designed for DVR-style playback of a growing file:
|
||||
- play_file() opens the recording and seeks to end (live edge)
|
||||
- seek() scrubs to any position (audio + video move together)
|
||||
- time_pos / duration track playback state for the slider
|
||||
Does not manage playback state — that's the Timeline's job.
|
||||
Provides: load, play, pause, seek, show_frame_at, render.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -48,17 +43,12 @@ class Player:
|
||||
"osc": False,
|
||||
"vo": "libmpv",
|
||||
"hwdec": "auto",
|
||||
"video_sync": "display-desync",
|
||||
# DVR: keep alive at EOF, wait for more data
|
||||
# Keep open at EOF so LIVE mode can wait for more data
|
||||
"keep_open": "yes",
|
||||
"demuxer_max_bytes": "500MiB",
|
||||
"demuxer_readahead_secs": "5",
|
||||
# Allow re-reading growing file
|
||||
"demuxer_cache_wait": True,
|
||||
"keep_open_pause": "no",
|
||||
}
|
||||
|
||||
log.info("Creating mpv player (OpenGL render, DVR mode)")
|
||||
self._player = libmpv.MPV(log_handler=self._mpv_log, loglevel="v", **opts)
|
||||
self._player = libmpv.MPV(log_handler=self._mpv_log, loglevel="warn", **opts)
|
||||
self._ctx = None
|
||||
self._update_callback = None
|
||||
log.info("mpv player created")
|
||||
@@ -70,14 +60,9 @@ class Player:
|
||||
log.error(msg)
|
||||
elif loglevel == "warn":
|
||||
log.warning(msg)
|
||||
else:
|
||||
log.debug(msg)
|
||||
|
||||
def init_gl(self, update_callback):
|
||||
"""Initialize the OpenGL render context.
|
||||
|
||||
Must be called with an active GL context.
|
||||
"""
|
||||
"""Initialize the OpenGL render context. Call with active GL context."""
|
||||
self._update_callback = update_callback
|
||||
self._ctx = libmpv.MpvRenderContext(
|
||||
self._player,
|
||||
@@ -88,7 +73,7 @@ class Player:
|
||||
)
|
||||
self._get_proc_address_ref = _get_proc_address
|
||||
self._ctx.update_cb = self._on_mpv_update
|
||||
log.info("mpv OpenGL render context initialized")
|
||||
log.info("mpv GL render context initialized")
|
||||
|
||||
def _on_mpv_update(self):
|
||||
if self._update_callback:
|
||||
@@ -102,51 +87,63 @@ class Player:
|
||||
opengl_fbo={"fbo": fbo, "w": width, "h": height},
|
||||
)
|
||||
|
||||
def play(self, source):
|
||||
"""Play from any source (URL, file path)."""
|
||||
log.info("mpv play: %s", source)
|
||||
self._player.play(str(source))
|
||||
def load(self, path):
|
||||
"""Load a recording file. Does not start playback."""
|
||||
log.info("mpv load: %s", path)
|
||||
self._player.loadfile(str(path), mode="replace")
|
||||
|
||||
def play_file(self, path):
|
||||
"""Play a recording file, seeking to the end (live edge)."""
|
||||
log.info("mpv play_file (DVR): %s", path)
|
||||
self._player.play(str(path))
|
||||
# Seek to end once playback starts
|
||||
self._player.observe_property("duration", self._seek_to_live_once)
|
||||
def load_live(self, url):
|
||||
"""Load a live stream URL with low-latency options."""
|
||||
self._player["cache"] = "no"
|
||||
self._player["demuxer-max-bytes"] = "512KiB"
|
||||
self._player["audio-buffer"] = 0.2
|
||||
log.info("mpv load_live: %s", url)
|
||||
self._player.loadfile(str(url), mode="replace")
|
||||
|
||||
def _seek_to_live_once(self, name, value):
|
||||
"""Seek to live edge once duration is known, then stop observing."""
|
||||
if value and value > 1:
|
||||
log.info("Seeking to live edge: %.1fs", value)
|
||||
self._player.seek(value - 0.5, reference="absolute")
|
||||
self._player.unobserve_property("duration", self._seek_to_live_once)
|
||||
def play(self):
|
||||
"""Resume/start playback."""
|
||||
self._player.pause = False
|
||||
|
||||
def pause(self):
|
||||
"""Pause playback."""
|
||||
self._player.pause = True
|
||||
|
||||
def resume(self):
|
||||
self._player.pause = False
|
||||
def seek(self, seconds):
|
||||
"""Seek to absolute position."""
|
||||
try:
|
||||
self._player.seek(seconds, reference="absolute")
|
||||
except Exception:
|
||||
pass # seek may fail if file not loaded yet
|
||||
|
||||
def show_frame_at(self, seconds):
|
||||
"""Pause and show the frame at the given timestamp."""
|
||||
self._player.pause = True
|
||||
try:
|
||||
self._player.seek(seconds, reference="absolute")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def time_pos(self):
|
||||
try:
|
||||
return self._player.time_pos
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def paused(self):
|
||||
return self._player.pause
|
||||
|
||||
def seek(self, seconds):
|
||||
"""Seek to absolute position. Audio + video move together."""
|
||||
self._player.seek(seconds, reference="absolute")
|
||||
|
||||
def seek_relative(self, seconds):
|
||||
"""Seek relative to current position."""
|
||||
self._player.seek(seconds, reference="relative")
|
||||
@property
|
||||
def idle(self):
|
||||
return self._player.core_idle
|
||||
|
||||
def screenshot(self, path):
|
||||
"""Save current frame as an image file."""
|
||||
self._player.screenshot_to_file(str(path), includes="video")
|
||||
log.debug("Screenshot saved: %s", path)
|
||||
|
||||
def stop(self):
|
||||
log.info("mpv stop")
|
||||
self._player.stop()
|
||||
try:
|
||||
self._player.screenshot_to_file(str(path), includes="video")
|
||||
except Exception as e:
|
||||
log.warning("Screenshot failed: %s", e)
|
||||
|
||||
def terminate(self):
|
||||
log.info("mpv terminate")
|
||||
@@ -157,21 +154,3 @@ class Player:
|
||||
self._player.terminate()
|
||||
except Exception as e:
|
||||
log.warning("mpv terminate error: %s", e)
|
||||
|
||||
@property
|
||||
def idle(self):
|
||||
return self._player.core_idle
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
try:
|
||||
return self._player.duration
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def time_pos(self):
|
||||
try:
|
||||
return self._player.time_pos
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
273
cht/ui/timeline.py
Normal file
273
cht/ui/timeline.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Timeline: state machine + shared slider for the recording.
|
||||
|
||||
State is a plain ViewState dataclass — two orthogonal flags replace the old
|
||||
4-state enum. A single "changed" GObject signal is emitted on any mutation;
|
||||
consumers read timeline.state directly.
|
||||
|
||||
live=True — watching UDP relay; play/pause and seek are disabled
|
||||
live=False — scrub mode; paused controls play/pause
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gtk, GLib, GObject
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ViewState:
|
||||
live: bool = False # True = watching live UDP relay
|
||||
paused: bool = False # scrub mode: is playback paused?
|
||||
cursor: float = 0.0 # current position in recording (seconds)
|
||||
duration: float = 0.0 # recording duration (seconds)
|
||||
scene_markers: list[float] = field(default_factory=list)
|
||||
|
||||
|
||||
class Timeline(GObject.Object):
|
||||
"""State machine for recording playback.
|
||||
|
||||
Single signal: "changed" — emitted on any state mutation.
|
||||
Consumers read timeline.state directly (no signal arguments).
|
||||
"""
|
||||
|
||||
__gsignals__ = {
|
||||
"changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.state = ViewState()
|
||||
log.info("Timeline created")
|
||||
|
||||
def _emit(self):
|
||||
self.emit("changed")
|
||||
|
||||
# -- Duration / cursor (called from background threads via GLib.idle_add) --
|
||||
|
||||
def set_duration(self, duration):
|
||||
"""Update recording duration. In live mode, cursor follows end."""
|
||||
if duration <= self.state.duration:
|
||||
return
|
||||
self.state.duration = duration
|
||||
if self.state.live:
|
||||
self.state.cursor = duration
|
||||
self._emit()
|
||||
|
||||
def set_cursor(self, position):
|
||||
"""Set cursor position (from slider drag or playback sync)."""
|
||||
position = max(0.0, min(position, self.state.duration))
|
||||
if abs(position - self.state.cursor) < 0.05:
|
||||
return
|
||||
self.state.cursor = position
|
||||
self._emit()
|
||||
|
||||
def tick_live(self):
|
||||
"""Advance cursor by 1s in live mode (smooth progression between probes)."""
|
||||
if not self.state.live:
|
||||
return
|
||||
self.state.cursor += 1.0
|
||||
# Only cap once duration is known — avoids clamping to 0 before first probe
|
||||
if self.state.duration > 0:
|
||||
self.state.cursor = min(self.state.cursor, self.state.duration)
|
||||
self._emit()
|
||||
|
||||
def add_scene_marker(self, timestamp):
|
||||
"""Add a scene change marker (does not emit — no UI update needed)."""
|
||||
self.state.scene_markers.append(timestamp)
|
||||
self.state.scene_markers.sort()
|
||||
|
||||
# -- User actions --
|
||||
|
||||
def go_live(self):
|
||||
"""Go to live mode at the recording end."""
|
||||
self.state.live = True
|
||||
self.state.paused = False
|
||||
self.state.cursor = self.state.duration
|
||||
self._emit()
|
||||
|
||||
def toggle_live(self, live_player_pos=None):
|
||||
"""Toggle between live and scrub mode.
|
||||
|
||||
When leaving live, cursor stays at the live player's actual position
|
||||
if provided, otherwise stays at current cursor.
|
||||
"""
|
||||
if self.state.live:
|
||||
# Enter scrub mode at the live player's current position
|
||||
self.state.live = False
|
||||
self.state.paused = True
|
||||
if live_player_pos is not None and live_player_pos > 0:
|
||||
pos = max(0.0, min(live_player_pos, self.state.duration))
|
||||
self.state.cursor = pos
|
||||
else:
|
||||
self.state.live = True
|
||||
self.state.paused = False
|
||||
self.state.cursor = self.state.duration
|
||||
self._emit()
|
||||
|
||||
def play(self):
|
||||
if self.state.live:
|
||||
return
|
||||
self.state.paused = False
|
||||
self._emit()
|
||||
|
||||
def pause(self):
|
||||
if self.state.live:
|
||||
return
|
||||
self.state.paused = True
|
||||
self._emit()
|
||||
|
||||
def seek(self, position):
|
||||
"""Seek to position — enters scrub mode, pauses."""
|
||||
self.state.live = False
|
||||
self.state.paused = True
|
||||
position = max(0.0, min(position, self.state.duration))
|
||||
if abs(position - self.state.cursor) >= 0.05:
|
||||
self.state.cursor = position
|
||||
self._emit()
|
||||
|
||||
def reset(self):
|
||||
"""Reset all state (called on disconnect)."""
|
||||
self.state = ViewState()
|
||||
self._emit()
|
||||
|
||||
|
||||
class TimelineControls(Gtk.Box):
|
||||
"""Shared slider + play/pause/live controls.
|
||||
|
||||
Play/Pause and slider are insensitive in live mode.
|
||||
LIVE button is a toggle — active style when live=True.
|
||||
"""
|
||||
|
||||
def __init__(self, timeline, **kwargs):
|
||||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, **kwargs)
|
||||
self._timeline = timeline
|
||||
self._updating_slider = False
|
||||
self._dragging = False
|
||||
self._wall_clock_start = None
|
||||
|
||||
self.set_margin_start(4)
|
||||
self.set_margin_end(4)
|
||||
self.set_margin_top(2)
|
||||
self.set_margin_bottom(4)
|
||||
|
||||
# Play/Pause button
|
||||
self._play_btn = Gtk.Button(label="Play")
|
||||
self._play_btn.connect("clicked", self._on_play_clicked)
|
||||
self.append(self._play_btn)
|
||||
|
||||
# Current time label
|
||||
self._time_label = Gtk.Label(label="00:00")
|
||||
self._time_label.set_width_chars(6)
|
||||
self.append(self._time_label)
|
||||
|
||||
# Slider
|
||||
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_value_changed)
|
||||
|
||||
press_ctrl = Gtk.GestureClick()
|
||||
press_ctrl.connect("pressed", self._on_slider_pressed)
|
||||
press_ctrl.connect("released", self._on_slider_released)
|
||||
press_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
|
||||
self._slider.add_controller(press_ctrl)
|
||||
self.append(self._slider)
|
||||
|
||||
# Duration label
|
||||
self._duration_label = Gtk.Label(label="00:00 / 00:00")
|
||||
self._duration_label.set_width_chars(14)
|
||||
self.append(self._duration_label)
|
||||
|
||||
# LIVE toggle button
|
||||
self._live_btn = Gtk.Button(label="LIVE")
|
||||
self._live_btn.connect("clicked", self._on_live_clicked)
|
||||
self.append(self._live_btn)
|
||||
|
||||
timeline.connect("changed", self._on_changed)
|
||||
GLib.timeout_add(1000, self._tick_total)
|
||||
|
||||
def _on_play_clicked(self, btn):
|
||||
s = self._timeline.state
|
||||
if s.paused:
|
||||
self._timeline.play()
|
||||
else:
|
||||
self._timeline.pause()
|
||||
|
||||
def set_live_toggle_callback(self, cb):
|
||||
"""Override the LIVE button handler. cb() should return the live player
|
||||
position (float or None) and call timeline.toggle_live() itself."""
|
||||
self._live_toggle_cb = cb
|
||||
|
||||
def _on_live_clicked(self, btn):
|
||||
if hasattr(self, "_live_toggle_cb"):
|
||||
self._live_toggle_cb()
|
||||
else:
|
||||
self._timeline.toggle_live()
|
||||
|
||||
def _on_slider_value_changed(self, slider):
|
||||
if self._dragging:
|
||||
self._time_label.set_text(self._fmt_time(slider.get_value()))
|
||||
|
||||
def _on_slider_pressed(self, gesture, n_press, x, y):
|
||||
self._dragging = True
|
||||
|
||||
def _on_slider_released(self, gesture, n_press, x, y):
|
||||
if self._dragging:
|
||||
self._dragging = False
|
||||
self._timeline.seek(self._slider.get_value())
|
||||
|
||||
def _on_changed(self, timeline):
|
||||
s = timeline.state
|
||||
|
||||
# Start wall clock when first going live (not on duration, which arrives ~30s later)
|
||||
if s.live and self._wall_clock_start is None:
|
||||
self._wall_clock_start = time.monotonic()
|
||||
|
||||
# Live mode: disable scrub controls
|
||||
self._play_btn.set_sensitive(not s.live)
|
||||
self._slider.set_sensitive(not s.live)
|
||||
|
||||
if s.live:
|
||||
self._live_btn.add_css_class("suggested-action")
|
||||
else:
|
||||
self._live_btn.remove_css_class("suggested-action")
|
||||
|
||||
# Play button label (only relevant in scrub mode)
|
||||
self._play_btn.set_label("Pause" if not s.paused else "Play")
|
||||
|
||||
# Slider position
|
||||
if not self._dragging:
|
||||
self._updating_slider = True
|
||||
self._slider.set_range(0, max(s.duration, 0.1))
|
||||
self._slider.set_value(s.cursor)
|
||||
self._updating_slider = False
|
||||
|
||||
self._time_label.set_text(self._fmt_time(s.cursor))
|
||||
self._update_duration_label()
|
||||
|
||||
def _tick_total(self):
|
||||
self._update_duration_label()
|
||||
return True
|
||||
|
||||
def _update_duration_label(self):
|
||||
s = self._timeline.state
|
||||
loaded = s.duration
|
||||
total = (time.monotonic() - self._wall_clock_start) if self._wall_clock_start else loaded
|
||||
self._duration_label.set_text(
|
||||
f"{self._fmt_time(loaded)} / {self._fmt_time(total)}"
|
||||
)
|
||||
|
||||
@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}"
|
||||
Reference in New Issue
Block a user