Stop the player freeing a render context libmpv still uses

The segfault: Qt destroys and recreates a widget's QOpenGLContext whenever it
moves to another top-level window, then calls initializeGL() again. libmpv
permits one render context per handle, so the second creation failed with
"There is already a mpv_render_context set" -- and the except branch assigned
self._render_ctx = None, dropping the last Python reference to the first
context, which libmpv was still holding a function pointer into. python-mpv's
MpvRenderContext has no __del__ and free() does not unregister the callback,
so the ctypes trampoline was collected while registered and the next frame
notification jumped into freed memory.

Two invariants fix it. initializeGL() now tears down any existing render
context first, so a second call is an ordinary recreation. Teardown clears
update_cb, calls free() with the GL context current, and only then drops the
reference -- and it is connected to QOpenGLContext.aboutToBeDestroyed, so it
runs before the GL context dies instead of never. The local reference during
teardown is load-bearing: it is what keeps the trampoline alive until free()
returns.

Three things were destroying that context. Fullscreen reparented the panel
into a new top-level window (twice per toggle) and put it back at the end of
the splitter, losing the pane layout; it now fullscreens the main window and
hides the chrome, reparenting nothing. The tab cross-fade and the dialog blur
both grab() the widget tree, which on an OpenGL surface forces a framebuffer
readback and returns black -- the fade is skipped for pages holding the video,
and dialogs dim rather than blur.

Verified on a real Wayland GL context: ten forced context destroy/recreate
cycles re-establish the render context every time, and the full app survives
tab switching, six fullscreen toggles and resizes with no render-context
error and a clean exit. Before this, the same startup dumped core.

Also here because they are one-line consequences of touching _create_mpv: an
explicit per-platform hwdec list ending in software decoding, and the restored
volume actually reaching mpv -- the slider set its value before connecting its
signal, so playback always started at 100.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:48:18 +02:00
parent 2420091a4e
commit 5ae2817eea
8 changed files with 517 additions and 69 deletions
+30 -17
View File
@@ -8,6 +8,7 @@ import markdown
from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer, Slot, QThread, Signal, QUrl, QPropertyAnimation, QEasingCurve, QPoint
from PySide6.QtGui import QIcon
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
@@ -542,6 +543,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
from .ytsage_gui_router import AppRouter
from .ytsage_gui_search import SearchPage
from .ytsage_gui_watch import WatchPage
from .ytsage_player_fullscreen import FullscreenController
self.router = AppRouter(self)
@@ -558,6 +560,14 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.main_tabs.addTab(self.download_page, _("main_tabs.downloads"))
self.setCentralWidget(self.main_tabs)
# Fullscreen is driven from here because the chrome it hides -- the tab
# bar and the queue panel -- belongs to the shell, not to the player.
if isinstance(self.watch_page.player, PlayerPanel):
self.fullscreen_controller = FullscreenController(
self, self.main_tabs, self.watch_page, parent=self
)
self.watch_page.player.set_fullscreen_controller(self.fullscreen_controller)
self.router.playVideo.connect(self._route_play_video)
self.router.queueVideo.connect(self.watch_page.enqueue)
self.router.downloadVideo.connect(self._route_download_video)
@@ -1861,25 +1871,28 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
def run_dialog_with_blur(self, dialog: QDialog) -> int:
"""Run a dialog with a static background screenshot blur to avoid QPainter conflicts."""
# 1. Capture the current state of the window (screenshot)
pixmap = self.grab()
# 2. Create the blur using a Graphics Scene method (much safer than QGraphicsBlurEffect on live widget)
# However, for simplicity and performance with PySide6, we can just apply a blur to the image
# or use a simplified overlay.
# Let's manually blur the pixmap or use a simpler transparent overlay if blur is too heavy manually.
# Actually, using QGraphicsBlurEffect on a temporary QGraphicsScene rendering to a pixmap is a valid way
# to generate a single blurred frame.
blurred_pixmap = self._apply_blur_to_pixmap(pixmap, radius=10)
# 3. Create an overlay widget that covers the Main Window
overlay = QLabel(self)
overlay.setPixmap(blurred_pixmap)
# The blur is a screenshot of the window. grab() forces a framebuffer
# readback on any QOpenGLWidget in the tree -- which returns black,
# and risks the GL context the embedded mpv player renders into. Every
# dialog in the app goes through here, so when a GL surface is present
# dim instead of blurring: same effect, no screenshot, and faster.
if self.findChild(QOpenGLWidget) is not None:
overlay = QWidget(self)
overlay.setAutoFillBackground(True)
overlay.setStyleSheet("background-color: rgba(0, 0, 0, 150);")
else:
blurred_pixmap = self._apply_blur_to_pixmap(self.grab(), radius=10)
overlay = QLabel(self)
overlay.setPixmap(blurred_pixmap)
overlay.setGeometry(0, 0, self.width(), self.height())
overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) # Block mouse
overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) # Block mouse
overlay.show()
# The opacity effect below goes on the overlay, which is a *sibling*
# of the video widget. A QGraphicsEffect on any ancestor of a
# QOpenGLWidget is unsupported and renders it black -- do not move it.
# Animate overlay Fade In
opacity_effect = QGraphicsOpacityEffect(overlay)
overlay.setGraphicsEffect(opacity_effect)