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

@@ -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