Files
WeAreSyncing/player_widget.py
ThePendulum 105dd5be88 LosslessCut-style layout: File menu, timeline above transport, centered controls, right-click play/pause
- 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).
2026-07-26 02:19:54 +02:00

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