diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b344d..89f2482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,42 @@ records. ## Unreleased +### Fixed + +- **The player no longer crashes the app.** Qt destroys and recreates a + widget's OpenGL context whenever it moves to another top-level window, and + calls `initializeGL()` again. libmpv allows one render context per handle, so + the second creation failed — and the failure path dropped the last reference + to the *first* context while libmpv still held a function pointer into it. + Python then collected the callback trampoline under libmpv's feet and the + next frame notification jumped into freed memory. `initializeGL()` now tears + down first so recreation is clean, teardown clears the callback and frees + with the GL context current, and it is wired to + `QOpenGLContext.aboutToBeDestroyed` so it runs before the context goes away + rather than never. Verified over ten forced context destroy/recreate cycles. +- **Fullscreen no longer reparents the video.** It took the player out of its + layout and into a new top-level window, destroying the OpenGL context twice + per toggle — the most direct route to the crash above — and on the way back + re-added the panel at the end of the splitter, so the video reappeared beside + the queue with the pane sizes lost. The main window now goes fullscreen and + the chrome around the video is hidden instead. Nothing is reparented. +- **Switching tabs no longer risks the player.** The cross-fade grabbed a + screenshot of the outgoing page; on an OpenGL surface that forces a + framebuffer readback and returns black, so the "fade" was a black slab and + the readback endangered the context. Pages containing the video now switch + without the fade. +- Dialogs dimmed instead of blurred while the player exists, for the same + reason — every dialog in the app screenshotted the whole window. +- The saved volume never reached mpv: the slider set its restored value before + its change signal was connected, so playback always started at 100. + ### Changed +- mpv is given an explicit `hwdec` list per platform (`player.hwdec`, default + `auto`), each ending in software decoding, so a machine with broken GPU + interop plays rather than showing a black frame. This does **not** silence + `Cannot load libcuda.so.1` — that comes from the driver stack below mpv and + appears with `hwdec=no` too. - **The update check now looks at SageTube's own releases.** It queried PyPI's `ytsage` package for the version and `oop7/YTSage` for the changelog, then linked to upstream's downloads — a different program's release stream. It now diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index b80ce2c..1e7f17e 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -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) diff --git a/ytsage/gui/ytsage_gui_player.py b/ytsage/gui/ytsage_gui_player.py index bc0609c..0b94c2f 100644 --- a/ytsage/gui/ytsage_gui_player.py +++ b/ytsage/gui/ytsage_gui_player.py @@ -16,9 +16,11 @@ callback on its own threads. Nothing in those callbacks may touch Qt widgets - they only emit queued Qt signals. """ +import sys +import threading from typing import Any, Dict, Optional -from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtCore import QCoreApplication, Qt, QTimer, Signal, Slot from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtGui import QOpenGLContext from PySide6.QtWidgets import ( @@ -78,8 +80,61 @@ def _build_ytdl_raw_options() -> str: return ",".join(opts) +def _default_hwdec() -> str: + """ + A platform-appropriate hwdec list. + + Left unset, mpv's decoder choice varies with how libmpv was built, which + makes playback problems unreproducible between machines. Naming the + candidates makes it deterministic, and every list ends in `no` so a + machine whose GPU interop is broken falls back to software decoding + rather than showing a black frame. + + Note this does *not* silence `Cannot load libcuda.so.1`: that comes from + the GL/driver stack below mpv and appears with `hwdec=no` too. It is + harmless stderr noise, not a decoder mpv chose. + """ + if sys.platform.startswith("win"): + return "d3d11va,dxva2-copy,no" + if sys.platform == "darwin": + return "videotoolbox,no" + return "vaapi,vaapi-copy,no" + + class MpvRenderWidget(QOpenGLWidget): - """QOpenGLWidget hosting a libmpv render context.""" + """ + QOpenGLWidget hosting a libmpv render context. + + Lifetime rule, and the reason this class is shaped the way it is + ------------------------------------------------------------------- + The **mpv handle** is independent of OpenGL and owns playback state, so it + lives as long as the widget. The **render context** belongs to exactly one + QOpenGLContext and must not outlive it. + + Qt destroys and recreates a widget's QOpenGLContext whenever the widget + moves to another top-level window, and calls initializeGL() again on the + new one. libmpv permits only one render context per handle, so the second + creation fails -- and the previous code responded by assigning + `self._render_ctx = None`, dropping the last Python reference to a context + 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 under libmpv's feet and + the next frame notification jumped into freed memory. That was the + segfault. + + Two invariants prevent it: + + 1. initializeGL() tears down any existing render context first, so a + second call is a clean recreation rather than an error. + 2. Teardown clears update_cb, calls free() with the GL context current, + and only then drops the reference -- and it is wired to + QOpenGLContext.aboutToBeDestroyed, so it runs *before* the GL context + goes away rather than never. + + Threading: libmpv fires property observers and the render-update callback + on its own threads. Nothing in those callbacks touches Qt widgets; they + only emit queued signals. + """ # Emitted from mpv threads; connected queued to GUI-thread slots mpvPositionChanged = Signal(float) @@ -95,14 +150,30 @@ class MpvRenderWidget(QOpenGLWidget): self.setMinimumHeight(240) self._mpv = None self._render_ctx = None - self._renderUpdateRequested.connect(self.update, Qt.ConnectionType.QueuedConnection) + self._gl_ctx = None + self._proc_addr_fn = None + self._shutting_down = False + # Gates the mpv-thread render callback. Cleared before free() so a + # notification arriving mid-teardown becomes a no-op. + self._render_alive = threading.Event() + self._renderUpdateRequested.connect(self._request_repaint, Qt.ConnectionType.QueuedConnection) + self.frameSwapped.connect(self._on_frame_swapped) self._create_mpv() + # closeEvent is not the only way this process ends. Without this, an + # exception during teardown or any exit that bypasses the main + # window's closeEvent leaves libmpv running against a dying + # interpreter. + app = QCoreApplication.instance() + if app is not None: + app.aboutToQuit.connect(self.shutdown) + # ------------------------------------------------------------------ mpv def _create_mpv(self) -> None: import mpv + hwdec = ConfigManager.get("player.hwdec") or "auto" kwargs: Dict[str, Any] = { "vo": "libmpv", "ytdl": True, @@ -110,6 +181,11 @@ class MpvRenderWidget(QOpenGLWidget): "idle": "yes", "osc": False, "input_default_bindings": False, + "hwdec": _default_hwdec() if hwdec == "auto" else str(hwdec), + # Applied here rather than only through the slider: the slider + # sets its restored value before its valueChanged is connected, + # so mpv never saw it and playback always started at 100. + "volume": max(0, min(100, int(ConfigManager.get("player.volume") or 100))), } ytdlp_path = get_yt_dlp_path() if str(ytdlp_path) != "yt-dlp": @@ -156,9 +232,45 @@ class MpvRenderWidget(QOpenGLWidget): # --------------------------------------------------------------- OpenGL + def _on_render_update(self) -> None: + """ + libmpv render thread. Must not touch Qt widgets. + + The alive gate matters: between clearing it and free() returning, + libmpv may still invoke this, and by then the widget may be on its way + out. + """ + if self._render_alive.is_set(): + self._renderUpdateRequested.emit() + + @Slot() + def _request_repaint(self) -> None: + if self._render_ctx is not None and not self._shutting_down: + self.update() + + @Slot() + def _on_frame_swapped(self) -> None: + """Let libmpv time its display-sync against real buffer swaps.""" + ctx = self._render_ctx + if ctx is None or self._shutting_down: + return + try: + ctx.report_swap() + except Exception as e: + logger.debug(f"mpv report_swap failed: {e}") + def initializeGL(self) -> None: from mpv import MpvGlGetProcAddressFn, MpvRenderContext + # A previous QOpenGLContext may still own a render context: Qt calls + # initializeGL again after a context loss, and libmpv allows exactly + # one per handle. Tearing down first is what turns "There is already a + # mpv_render_context set" into an ordinary recreation. + self._teardown_render_context() + + if self._mpv is None or self._shutting_down: + return + def get_proc_address(_ctx, name): glctx = QOpenGLContext.currentContext() if glctx is None: @@ -166,45 +278,141 @@ class MpvRenderWidget(QOpenGLWidget): address = glctx.getProcAddress(name if isinstance(name, bytes) else name.encode("utf-8")) return int(address) if address else 0 - self._get_proc_address = MpvGlGetProcAddressFn(get_proc_address) + # Held on the instance: libmpv keeps the raw pointer and some drivers + # resolve symbols lazily, well after creation returns. + proc_addr_fn = MpvGlGetProcAddressFn(get_proc_address) try: - self._render_ctx = MpvRenderContext( + render_ctx = MpvRenderContext( self._mpv, "opengl", - opengl_init_params={"get_proc_address": self._get_proc_address}, + opengl_init_params={"get_proc_address": proc_addr_fn}, ) - self._render_ctx.update_cb = self._renderUpdateRequested.emit # mpv thread except Exception as e: # Leave the widget black but keep the app alive (e.g. software GL # contexts that libmpv rejects) logger.error(f"Failed to create mpv render context: {e}") - self._render_ctx = None self.mpvError.emit(f"Video output initialization failed: {e}") + return + + self._proc_addr_fn = proc_addr_fn + self._render_ctx = render_ctx + self._render_alive.set() + render_ctx.update_cb = self._on_render_update # mpv thread + + gl_ctx = QOpenGLContext.currentContext() + self._gl_ctx = gl_ctx + if gl_ctx is not None: + # The one hook the old code was missing. Without it the render + # context outlives the GL context that owns it -- or is never + # freed at all. + gl_ctx.aboutToBeDestroyed.connect( + self._on_gl_context_about_to_be_destroyed, + Qt.ConnectionType.DirectConnection, + ) + + @Slot() + def _on_gl_context_about_to_be_destroyed(self) -> None: + """GUI thread. free() requires its GL context to be current.""" + try: + self.makeCurrent() + self._teardown_render_context() + finally: + try: + self.doneCurrent() + except Exception: + pass + + def _teardown_render_context(self) -> None: + """ + Release the render context, in the only order that is safe. + + The local reference is load-bearing: it keeps the object -- and the + ctypes trampoline libmpv points at -- alive until free() has returned. + Dropping it earlier is precisely the use-after-free this class exists + to avoid. + """ + render_ctx, self._render_ctx = self._render_ctx, None + gl_ctx, self._gl_ctx = self._gl_ctx, None + self._render_alive.clear() + + if gl_ctx is not None: + try: + gl_ctx.aboutToBeDestroyed.disconnect(self._on_gl_context_about_to_be_destroyed) + except (RuntimeError, TypeError): + # Already disconnected, or the C++ object is gone. + pass + + if render_ctx is None: + self._proc_addr_fn = None + return + + try: + # Note this does *not* unregister: python-mpv installs a no-op + # wrapper instead. It only guarantees that a callback arriving + # before free() does nothing. free() is what actually unsets the + # callback and blocks until in-flight invocations return. + render_ctx.update_cb = None + except Exception as e: + logger.debug(f"Clearing mpv update callback failed: {e}") + + try: + render_ctx.free() + except Exception as e: + logger.debug(f"Error freeing mpv render context: {e}") + finally: + self._proc_addr_fn = None def paintGL(self) -> None: - if self._render_ctx is None: + # Alias first: self._render_ctx can be cleared by a teardown between + # the check and the render. + render_ctx = self._render_ctx + if render_ctx is None or self._shutting_down: return ratio = self.devicePixelRatioF() w = int(self.width() * ratio) h = int(self.height() * ratio) - self._render_ctx.render( - flip_y=True, - opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h}, - ) + if w <= 0 or h <= 0: + return + try: + render_ctx.render( + flip_y=True, + opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h}, + ) + except Exception as e: + logger.debug(f"mpv render failed: {e}") + + def resizeGL(self, w: int, h: int) -> None: + # The FBO size travels with every render call, so nothing needs + # resizing here -- but a resize while paused must still repaint, or + # the last frame stays stretched to the old geometry. + self.update() def shutdown(self) -> None: + """Idempotent: reachable from closeEvent, aboutToQuit and WatchPage.""" + if self._shutting_down: + return + self._shutting_down = True + + # Render context before the handle: terminating mpv while a render + # context is registered is undefined. try: - if self._render_ctx is not None: - self._render_ctx.free() - self._render_ctx = None + if self.context() is not None: + self.makeCurrent() + try: + self._teardown_render_context() + finally: + self.doneCurrent() + else: + self._teardown_render_context() except Exception as e: - logger.debug(f"Error freeing mpv render context: {e}") - try: - if self._mpv is not None: - self._mpv.terminate() - self._mpv = None - except Exception as e: - logger.debug(f"Error terminating mpv: {e}") + logger.debug(f"Error tearing down mpv render context: {e}") + + mpv_inst, self._mpv = self._mpv, None + if mpv_inst is not None: + try: + mpv_inst.terminate() + except Exception as e: + logger.debug(f"Error terminating mpv: {e}") # ------------------------------------------------------------- controls @@ -227,7 +435,7 @@ class PlayerPanel(QWidget): self._current_entry: Dict[str, Any] = {} self._duration: float = 0.0 self._slider_down = False - self._fullscreen_holder: Optional[QWidget] = None + self._fullscreen_controller = None layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -472,32 +680,32 @@ class PlayerPanel(QWidget): except Exception: pass - def toggle_fullscreen(self) -> None: - if self._fullscreen_holder is None: - self._fullscreen_parent_layout = self.parentWidget().layout() if self.parentWidget() else None - self._fullscreen_holder = self.parentWidget() - self.setParent(None) - self.setWindowFlags(Qt.WindowType.Window) - self.showFullScreen() - else: - self.setWindowFlags(Qt.WindowType.Widget) - if self._fullscreen_parent_layout is not None: - self._fullscreen_parent_layout.addWidget(self) - else: - self.setParent(self._fullscreen_holder) - self.showNormal() - self.show() - self._fullscreen_holder = None + def set_fullscreen_controller(self, controller) -> None: + """ + Injected by the main window, which owns the chrome being hidden. - def keyPressEvent(self, event) -> None: - if event.key() == Qt.Key.Key_Escape and self._fullscreen_holder is not None: - self.toggle_fullscreen() - elif event.key() == Qt.Key.Key_Space: - self.toggle_pause() - elif event.key() == Qt.Key.Key_F: - self.toggle_fullscreen() - else: - super().keyPressEvent(event) + The panel deliberately does not implement fullscreen itself: the old + version reparented itself into a top-level window, which destroyed the + QOpenGLContext underneath it twice per toggle and was the most direct + route to the render-context crash. + """ + self._fullscreen_controller = controller + + def is_fullscreen(self) -> bool: + controller = self._fullscreen_controller + return bool(controller is not None and controller.is_active()) + + def toggle_fullscreen(self) -> None: + controller = self._fullscreen_controller + if controller is None: + logger.debug("Fullscreen requested but no controller is attached.") + return + controller.toggle() + + def exit_fullscreen(self) -> None: + controller = self._fullscreen_controller + if controller is not None and controller.is_active(): + controller.exit() class PlayerUnavailablePanel(QWidget): diff --git a/ytsage/gui/ytsage_gui_watch.py b/ytsage/gui/ytsage_gui_watch.py index 284a5aa..fa57b6e 100644 --- a/ytsage/gui/ytsage_gui_watch.py +++ b/ytsage/gui/ytsage_gui_watch.py @@ -44,7 +44,9 @@ class WatchPage(QWidget): self.player = create_player_panel(self) splitter.addWidget(self.player) - queue_panel = QWidget(self) + # Kept as an attribute: FullscreenController hides it rather than + # reparenting the player, which is what used to lose the GL context. + self.queue_panel = queue_panel = QWidget(self) queue_layout = QVBoxLayout(queue_panel) queue_layout.setContentsMargins(4, 0, 0, 0) diff --git a/ytsage/gui/ytsage_player_fullscreen.py b/ytsage/gui/ytsage_player_fullscreen.py new file mode 100644 index 0000000..11647ed --- /dev/null +++ b/ytsage/gui/ytsage_player_fullscreen.py @@ -0,0 +1,140 @@ +""" +Fullscreen without reparenting the video widget +=============================================== + +The obvious implementation -- take the player out of its layout, make it a +top-level window, `showFullScreen()` -- is what SageTube did, and it is a +reliable way to crash. Reparenting a widget into a new top-level destroys and +recreates the QOpenGLContext of every QOpenGLWidget beneath it, and each +recreation is another chance to lose the libmpv render context. A single +fullscreen toggle did it twice. + +So nothing is reparented here. The **main window** goes fullscreen and the +chrome around the video is hidden. QMainWindow.showFullScreen() reuses the +same QWindow and native surface -- on Wayland it is an +`xdg_toplevel.set_fullscreen` -- so the GL context is untouched and the video +never blinks. + +The same mechanism gives "cinema mode" (hide the chrome, stay windowed) for +free, and it gives Escape one unambiguous owner. +""" + +from typing import Optional + +from PySide6.QtCore import QObject, Qt, Signal +from PySide6.QtWidgets import QMainWindow, QWidget + +from ..utils.ytsage_logger import logger + + +class FullscreenController(QObject): + """ + Drives fullscreen for the Watch page. + + Owns no widgets and destroys nothing; it only toggles visibility and the + main window's state, and can always put things back. + """ + + changed = Signal(bool) + + def __init__( + self, + window: QMainWindow, + tabs: QWidget, + watch_page: QWidget, + parent: Optional[QObject] = None, + ) -> None: + super().__init__(parent) + self._window = window + self._tabs = tabs + self._watch_page = watch_page + self._active = False + self._prev_window_state: Optional[Qt.WindowState] = None + self._prev_margins = None + + # ------------------------------------------------------------------ state + + def is_active(self) -> bool: + return self._active + + def toggle(self) -> None: + self.exit() if self._active else self.enter() + + # ------------------------------------------------------------ transitions + + def enter(self) -> None: + if self._active: + return + try: + # Fullscreening while another tab is showing would present an + # empty screen, so make sure the video is the visible page first. + self._show_watch_tab() + + self._prev_window_state = self._window.windowState() + + tab_bar = getattr(self._tabs, "tab_bar", None) + if tab_bar is not None: + tab_bar.hide() + corner = getattr(self._tabs, "corner_widget", None) + if corner is not None: + corner.hide() + + queue_panel = getattr(self._watch_page, "queue_panel", None) + if queue_panel is not None: + queue_panel.hide() + + layout = self._watch_page.layout() + if layout is not None: + self._prev_margins = layout.contentsMargins() + layout.setContentsMargins(0, 0, 0, 0) + + self._window.showFullScreen() + self._active = True + self.changed.emit(True) + except Exception as e: + logger.exception(f"Entering fullscreen failed: {e}") + # Never leave the UI half-hidden with no way back. + self.exit() + + def exit(self) -> None: + if not self._active and self._prev_window_state is None: + return + try: + tab_bar = getattr(self._tabs, "tab_bar", None) + if tab_bar is not None: + tab_bar.show() + corner = getattr(self._tabs, "corner_widget", None) + if corner is not None: + corner.show() + + queue_panel = getattr(self._watch_page, "queue_panel", None) + if queue_panel is not None: + queue_panel.show() + + layout = self._watch_page.layout() + if layout is not None and self._prev_margins is not None: + layout.setContentsMargins(self._prev_margins) + + if self._prev_window_state is not None: + self._window.setWindowState(self._prev_window_state) + else: + self._window.showNormal() + except Exception as e: + logger.exception(f"Leaving fullscreen failed: {e}") + finally: + self._prev_window_state = None + self._prev_margins = None + self._active = False + self.changed.emit(False) + + # ---------------------------------------------------------------- helpers + + def _show_watch_tab(self) -> None: + stack = getattr(self._tabs, "stack", None) + set_index = getattr(self._tabs, "set_current_index", None) + if stack is None or set_index is None: + return + for i in range(stack.count()): + if stack.widget(i) is self._watch_page: + set_index(i) + return diff --git a/ytsage/gui/ytsage_smooth_tab_widget.py b/ytsage/gui/ytsage_smooth_tab_widget.py index 375b2ba..237c9bd 100644 --- a/ytsage/gui/ytsage_smooth_tab_widget.py +++ b/ytsage/gui/ytsage_smooth_tab_widget.py @@ -1,5 +1,6 @@ from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt from PySide6.QtGui import QPixmap +from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtWidgets import ( QFrame, QGraphicsOpacityEffect, @@ -19,6 +20,13 @@ class FadingStackedWidget(QStackedWidget): self.fade_duration = 300 self.fade_easing = QEasingCurve.Type.OutQuad + @staticmethod + def _contains_gl(widget): + """Whether an OpenGL surface lives anywhere under this page.""" + if widget is None: + return False + return isinstance(widget, QOpenGLWidget) or widget.findChild(QOpenGLWidget) is not None + def setCurrentIndex(self, index): curr_index = self.currentIndex() if index == curr_index: @@ -26,12 +34,21 @@ class FadingStackedWidget(QStackedWidget): widget = self.widget(index) curr_widget = self.widget(curr_index) - + # If widget isn't visible or valid, just swap if not self.isVisible() or not curr_widget: super().setCurrentIndex(index) return + # Never fade a page containing an OpenGL surface. grab() on one + # returns a black rectangle -- so the "fade" was a black slab sliding + # over the new tab -- and forcing a framebuffer readback plus a + # QGraphicsEffect over that subtree is a way to lose the GL context, + # which for the embedded mpv player means a crash. + if self._contains_gl(widget) or self._contains_gl(curr_widget): + super().setCurrentIndex(index) + return + # 1. Capture the current view (the "old" tab) # Use grab() for simplicity and reliability in PySide6 pixmap = self.grab() diff --git a/ytsage/main.py b/ytsage/main.py index 889c0f5..a4247c8 100644 --- a/ytsage/main.py +++ b/ytsage/main.py @@ -1,11 +1,39 @@ import sys +from PySide6.QtCore import QCoreApplication, Qt +from PySide6.QtGui import QSurfaceFormat from PySide6.QtWidgets import QApplication, QMessageBox from .utils.ytsage_logger import logger from .gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main +def _configure_opengl() -> None: + """ + Must run before QApplication exists -- both settings are ignored afterwards. + + AA_ShareOpenGLContexts puts every widget context in one sharing group, so + a reparent no longer loses GL *resources*. It does not stop Qt destroying + and recreating the widget's own context (MpvRenderWidget handles that); + it removes a whole second class of failure around it. + + The surface format is deliberately minimal. mpv renders into our FBO and + needs no depth, stencil or alpha from the default framebuffer, and asking + for them is what produced `OpenGL error INVALID_ENUM` on some drivers. No + GL version or profile is requested on purpose: pinning a core profile + breaks software and GLES stacks that libmpv would otherwise accept. + """ + QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) + + fmt = QSurfaceFormat() + fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer) + fmt.setSwapInterval(1) + fmt.setDepthBufferSize(0) + fmt.setStencilBufferSize(0) + fmt.setAlphaBufferSize(0) + QSurfaceFormat.setDefaultFormat(fmt) + + def show_error_dialog(message): # A QMessageBox needs a live QApplication; if startup failed before (or # while) creating one, constructing the dialog would abort the process @@ -23,7 +51,8 @@ def show_error_dialog(message): def main(): try: - logger.info("Starting YTSage application") + logger.info("Starting SageTube application") + _configure_opengl() app = QApplication(sys.argv) app.setApplicationName("SageTube") app.setDesktopFileName("sagetube") diff --git a/ytsage/utils/ytsage_config_manager.py b/ytsage/utils/ytsage_config_manager.py index a363cb4..9797ae7 100644 --- a/ytsage/utils/ytsage_config_manager.py +++ b/ytsage/utils/ytsage_config_manager.py @@ -118,6 +118,11 @@ class ConfigManager: "volume": 100, "resume": "auto", # auto | off "source_mode": "ytdl", # ytdl (mpv ytdl_hook) | direct (raw stream URLs) + # "auto" picks a platform list (vaapi on Linux, d3d11va on Windows, + # videotoolbox on macOS), each ending in "no" so a broken interop + # falls back to software rather than a black frame. Any other value + # is passed to mpv verbatim; "no" forces software decoding. + "hwdec": "auto", }, "feed": { "mode": "local", # local (per-channel aggregation) | account (cookies)