- Clap detection moved from inline buttons to a Tools menu, plain labels (no more '(whole file)' / '(from cursor)' parentheticals) -- status messages moved to the window's status bar since the dedicated label widget went away with the button row. - ClickableSlider subclass: clicking anywhere along the seek track jumps straight there instead of QSlider's default page-by-a-step behavior; reposition-then-delegate-to-super keeps native drag behavior intact. - Dragging now seeks live via sliderMoved instead of waiting for sliderReleased. - PlayerWidget accepts drag-and-drop of a local file to load it. Verified with real Qt events (QMouseEvent clicks, a QDropEvent, a real mouse click via xdotool opening the Tools menu on an actual running window) rather than just calling the underlying methods directly.
125 lines
4.8 KiB
Python
125 lines
4.8 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)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
|
|
self.setAcceptDrops(True)
|
|
|
|
# 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
|
|
|
|
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
|