fix: replace wid embedding with OpenGL render context — works on Wayland and X11

This commit is contained in:
2026-04-06 20:48:59 +02:00
parent 2c95c2618a
commit 1e0c1ae892
+54 -50
View File
@@ -17,6 +17,7 @@ from PyQt6.QtWidgets import (
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip, QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
QComboBox, QDialog, QPlainTextEdit, QCheckBox, QComboBox, QDialog, QPlainTextEdit, QCheckBox,
) )
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeyEvent from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeyEvent
import mpv import mpv
@@ -441,91 +442,98 @@ class TimelineWidget(QWidget):
self._seek_timer.start() # debounce the mpv seek self._seek_timer.start() # debounce the mpv seek
class MpvWidget(QFrame): def _mpv_get_proc_address(_, name):
"""Resolve OpenGL/EGL function pointers for mpv's render context."""
import ctypes, ctypes.util
for lib_name in ("EGL", "GL"):
lib_path = ctypes.util.find_library(lib_name)
if not lib_path:
continue
try:
lib = ctypes.CDLL(lib_path)
for fn_name in ("eglGetProcAddress", "glXGetProcAddressARB", "glXGetProcAddress"):
fn = getattr(lib, fn_name, None)
if fn is None:
continue
fn.restype = ctypes.c_void_p
fn.argtypes = [ctypes.c_char_p]
addr = fn(name)
if addr:
return addr
except Exception:
continue
return None
class MpvWidget(QOpenGLWidget):
file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready
crop_clicked = pyqtSignal(float) # x fraction 01 when user clicks video crop_clicked = pyqtSignal(float) # x fraction 01 when user clicks video
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setMinimumSize(640, 360) self.setMinimumSize(640, 360)
self.setStyleSheet("background: black;") self._player = mpv.MPV(keep_open=True, pause=True)
# Required so Qt creates a real native window handle for mpv to embed into. self._render_ctx = None
# Without these, mpv opens a separate window instead of embedding.
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen, True)
self._player = None
def paintEngine(self):
# mpv owns the native window; Qt has no paint engine here.
# Returning None suppresses the "paintEngine == 0" warnings.
return None
def paintEvent(self, event):
# QFrame's default paintEvent would call QPainter on this widget,
# which has no engine (mpv owns the surface). Do nothing instead.
pass
def _init_player(self):
if self._player is not None:
return
_wd = os.environ.pop("WAYLAND_DISPLAY", None)
wid = int(self.winId())
print(f"[mpv] platform={QApplication.platformName()} wid={wid} WAYLAND_DISPLAY was={_wd}", flush=True)
try:
self._player = mpv.MPV(
wid=str(wid),
keep_open=True,
pause=True,
)
finally:
if _wd is not None:
os.environ["WAYLAND_DISPLAY"] = _wd
# mpv fires events on its own thread; bounce to Qt thread via QTimer.
@self._player.event_callback("file-loaded") @self._player.event_callback("file-loaded")
def _on_file_loaded(event): def _on_file_loaded(event):
QTimer.singleShot(0, self.file_loaded.emit) QTimer.singleShot(0, self.file_loaded.emit)
def initializeGL(self):
self._render_ctx = mpv.MpvRenderContext(
self._player, "opengl",
opengl_init_params={"get_proc_address": _mpv_get_proc_address},
)
self._render_ctx.update_cb = self._on_mpv_update
def _on_mpv_update(self):
# Called from mpv thread; schedule a repaint on the Qt thread.
self.update()
def paintGL(self):
if self._render_ctx:
fbo = self.defaultFramebufferObject()
r = self.devicePixelRatio()
self._render_ctx.render(
flip_y=True,
opengl_fbo={"w": int(self.width() * r), "h": int(self.height() * r), "fbo": fbo},
)
def resizeGL(self, w, h):
if self._render_ctx:
self.update()
def load(self, path: str): def load(self, path: str):
self._init_player()
self._player.play(path) self._player.play(path)
def seek(self, t: float): def seek(self, t: float):
if self._player:
self._player.pause = True self._player.pause = True
self._player.seek(t, "absolute") self._player.seek(t, "absolute")
def play_loop(self, a: float, b: float): def play_loop(self, a: float, b: float):
if self._player:
self._player["ab-loop-a"] = a self._player["ab-loop-a"] = a
# Clamp b to duration so AB loop fires even on clips shorter than 8s. # Clamp b to duration so AB loop fires even on clips shorter than 8s.
self._player["ab-loop-b"] = min(b, self._player.duration or b) self._player["ab-loop-b"] = min(b, self._player.duration or b)
self._player.pause = False self._player.pause = False
def stop_loop(self): def stop_loop(self):
if self._player:
# ab-loop-a/b are numeric properties — setting to "no" via dict # ab-loop-a/b are numeric properties — setting to "no" via dict
# accessor throws TypeError. Disable loop via ab_loop_count instead. # accessor throws TypeError. Disable loop via ab_loop_count instead.
self._player.ab_loop_count = 0 self._player.ab_loop_count = 0
self._player.pause = True self._player.pause = True
def get_duration(self) -> float: def get_duration(self) -> float:
if self._player:
d = self._player.duration d = self._player.duration
return d if d else 0.0 return d if d else 0.0
return 0.0
def get_video_size(self) -> tuple[int, int]: def get_video_size(self) -> tuple[int, int]:
if self._player:
return (self._player.width or 0, self._player.height or 0) return (self._player.width or 0, self._player.height or 0)
return (0, 0)
def get_fps(self) -> float: def get_fps(self) -> float:
if self._player:
return self._player.container_fps or 25.0 return self._player.container_fps or 25.0
return 25.0
def is_playing(self) -> bool: def is_playing(self) -> bool:
return bool(self._player and not self._player.pause) return not self._player.pause
def mousePressEvent(self, event): def mousePressEvent(self, event):
w = self.width() w = self.width()
@@ -533,7 +541,8 @@ class MpvWidget(QFrame):
self.crop_clicked.emit(event.position().x() / w) self.crop_clicked.emit(event.position().x() / w)
def closeEvent(self, event): def closeEvent(self, event):
if self._player: if self._render_ctx:
self._render_ctx.free()
self._player.terminate() self._player.terminate()
super().closeEvent(event) super().closeEvent(event)
@@ -812,11 +821,6 @@ class SettingsDialog(QDialog):
def main(): def main():
# Force X11/XCB (XWayland) so mpv can embed via wid.
# mpv's wid parameter requires an X11 window ID; it does not work with
# Wayland surface handles. Override rather than setdefault so this takes
# effect even when QT_QPA_PLATFORM=wayland is already set by the session.
os.environ["QT_QPA_PLATFORM"] = "xcb"
app = QApplication(sys.argv) app = QApplication(sys.argv)
locale.setlocale(locale.LC_NUMERIC, "C") # QApplication resets locale; re-apply for libmpv locale.setlocale(locale.LC_NUMERIC, "C") # QApplication resets locale; re-apply for libmpv
app.setStyle("Fusion") app.setStyle("Fusion")