- Load File moved from a button into a new File menu. - Timeline now sits directly under the video; the transport row (frame-step/play/frame-step, centered as a group; mute pinned right) moved below it, matching LosslessCut's layout instead of the previous buttons-then-timeline order. - Right-click on the video toggles play/pause. The right-click implementation took two tries, worth recording: mpv embeds via wid as its own native child window, which receives raw X11 input directly -- a real mouse click never reaches this QWidget's mousePressEvent at all, even though a unit-style test that called the handler directly passed and looked fine. Caught this by testing with an actual xdotool click against a live running window instead of trusting the synthetic test, which is what this project's smoke tests have been doing throughout and what caught the bug here too. Fixed by binding MBTN_RIGHT through mpv's own on_key_press input API instead, which does receive the real clicks -- verified the same way, with a real click on a live window, position/button-text actually changing. Also fixed a genuinely flaky smoke test in the process: QShortcut's active-window check races with real desktop window-manager focus, especially with another instance of this same app already open elsewhere (happened during this session). Switched that test to set Qt's active-window state directly rather than relying on WM-level activation, and independently confirmed the real key bindings with live xdotool keypresses (both directions, plus end-of-file clamping).
135 lines
5.4 KiB
Python
135 lines
5.4 KiB
Python
import locale
|
|
import os
|
|
import time
|
|
|
|
# The dev machine has a locally-built libmpv in /usr/local/lib that isn't
|
|
# registered with ldconfig. LD_LIBRARY_PATH must be set in the environment
|
|
# *before this process starts* (dlopen reads it at exec time) -- see run
|
|
# scripts / README for the wrapper that does this.
|
|
|
|
import mpv
|
|
from PyQt6.QtWidgets import QWidget
|
|
from PyQt6.QtCore import Qt, pyqtSignal
|
|
|
|
|
|
class PlayerWidget(QWidget):
|
|
"""Embeds an mpv render surface in a Qt widget.
|
|
|
|
Frame-accurate seeking relies on mpv's hr-seek (decode-and-discard to the
|
|
exact target instead of snapping to the nearest keyframe), which is why
|
|
this exists instead of a QMediaPlayer/HTML5-video-style backend.
|
|
"""
|
|
|
|
fileDropped = pyqtSignal(str)
|
|
rightClicked = pyqtSignal()
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
|
|
self.setAcceptDrops(True)
|
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
|
|
|
|
# QApplication resets LC_NUMERIC to the system locale on construction,
|
|
# which undoes python-mpv's own import-time fix and makes libmpv abort
|
|
# with "Non-C locale detected" -- reset it again right before libmpv
|
|
# actually gets instantiated.
|
|
locale.setlocale(locale.LC_NUMERIC, "C")
|
|
|
|
self.mpv = mpv.MPV(
|
|
wid=str(int(self.winId())),
|
|
vo="gpu",
|
|
hr_seek="yes",
|
|
hr_seek_framedrop="no",
|
|
keep_open="yes",
|
|
pause=True,
|
|
)
|
|
self._fps = None
|
|
|
|
# mpv owns its own native child window for rendering (that's how
|
|
# wid-embedding works), which receives raw X11 input directly --
|
|
# confirmed by testing that a real mouse click never reaches this
|
|
# QWidget's mousePressEvent at all, even though it looked fine when
|
|
# the handler was called directly in a unit-style test. Mouse
|
|
# buttons have to be bound through mpv's own input system instead.
|
|
self.mpv.on_key_press("MBTN_RIGHT")(lambda: self.rightClicked.emit())
|
|
|
|
def load(self, path: str):
|
|
# NOTE: we start the MPV instance with pause=True, so `core-idle`
|
|
# never flips to False and mpv.wait_until_playing() would block
|
|
# forever. Wait for `duration` to populate instead, which happens
|
|
# once the file is loaded regardless of pause state.
|
|
self.mpv.play(path)
|
|
self.mpv.wait_for_property("duration", timeout=10)
|
|
self.mpv.pause = True
|
|
self._fps = self.mpv.container_fps or self.mpv.estimated_vf_fps
|
|
|
|
def play(self):
|
|
self.mpv.pause = False
|
|
|
|
def pause(self):
|
|
self.mpv.pause = True
|
|
|
|
def seek(self, seconds: float):
|
|
before = self.mpv.estimated_frame_number
|
|
self.mpv.seek(seconds, reference="absolute", precision="exact")
|
|
self._wait_for_frame_change(before)
|
|
|
|
def step_frame(self, n: int = 1):
|
|
# frame-step is async: the command returns immediately and the
|
|
# actual decode/seek happens on mpv's internal thread. Without
|
|
# waiting for confirmation, rapid-fire commands get coalesced or
|
|
# dropped (observed: some steps silently no-op, others jump 2-3
|
|
# frames at once when driven by a fixed sleep guess).
|
|
#
|
|
# `playback-restart` -- the event mpv's own docs point to for this --
|
|
# turned out to never fire with our vo=gpu Qt-embedded surface (nor
|
|
# with vo=null in an isolated test), even though the underlying
|
|
# decode/seek completes correctly and quickly. So instead we poll
|
|
# `estimated_frame_number` directly until it changes, which was
|
|
# confirmed reliable in isolated testing (see scripts/mpv_bare_test.py).
|
|
cmd = "frame-step" if n >= 0 else "frame-back-step"
|
|
for _ in range(abs(n)):
|
|
before = self.mpv.estimated_frame_number
|
|
self.mpv.command(cmd)
|
|
self._wait_for_frame_change(before)
|
|
|
|
def _wait_for_frame_change(self, before, timeout=1.0):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
current = self.mpv.estimated_frame_number
|
|
if current is not None and current != before:
|
|
return
|
|
time.sleep(0.005)
|
|
|
|
def get_position(self) -> float:
|
|
return self.mpv.time_pos or 0.0
|
|
|
|
def get_estimated_frame(self) -> int:
|
|
return self.mpv.estimated_frame_number or 0
|
|
|
|
def fps(self) -> float:
|
|
return self._fps or 0.0
|
|
|
|
def screenshot(self, path: str):
|
|
# `estimated_frame_number` updates as soon as the demuxer/decoder
|
|
# reports the new position, slightly ahead of the vo=gpu surface
|
|
# actually finishing its render -- give it a couple frames' worth
|
|
# of slack so the screenshot doesn't grab the previous frame.
|
|
time.sleep(0.05)
|
|
self.mpv.screenshot_to_file(path, includes="video")
|
|
|
|
def shutdown(self):
|
|
self.mpv.terminate()
|
|
|
|
def dragEnterEvent(self, event):
|
|
if event.mimeData().hasUrls() and any(u.isLocalFile() for u in event.mimeData().urls()):
|
|
event.acceptProposedAction()
|
|
|
|
def dropEvent(self, event):
|
|
for url in event.mimeData().urls():
|
|
if url.isLocalFile():
|
|
self.fileDropped.emit(url.toLocalFile())
|
|
event.acceptProposedAction()
|
|
return
|