Files
SageTube/ytsage/gui/ytsage_gui_player.py
T
Homer 9c4749e49b Release 5.5.0
Also stops one benign libmpv message being logged as an error: `after
creating texture: OpenGL error INVALID_ENUM` comes from the GL driver, arrives
several times per playback start and per fullscreen toggle, and playback
continues regardless. Hundreds of lines a session buried the errors that
matter -- it is a debug line now.

Verified end to end on the real display with an actual video playing: ten tab
switches and four fullscreen toggles while frames were rendering, position
advancing throughout, the render context intact, no crash, and an empty error
log afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:18:43 +02:00

1145 lines
45 KiB
Python

"""
Embedded mpv player
===================
MpvRenderWidget renders libmpv into a QOpenGLWidget via the mpv render API
(the wid=winId() embedding path is broken on native Wayland, the render API
works on X11/Wayland/Windows/macOS alike).
PlayerPanel wraps the render widget with transport controls and resolves
YouTube URLs through mpv's ytdl_hook, pointed at the app-managed yt-dlp
binary, so stream URL freshness, DASH muxing, subtitles and PO-token/nsig
handling are all handled by the same yt-dlp the downloader uses.
Threading rule: libmpv fires property observers and the render-update
callback on its own threads. Nothing in those callbacks may touch Qt
widgets - they only emit queued Qt signals.
"""
import sys
import threading
import webbrowser
from typing import Any, Dict, Optional
from PySide6.QtCore import QCoreApplication, Qt, QTimer, Signal, Slot
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtGui import QOpenGLContext
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSlider,
QVBoxLayout,
QWidget,
)
from . import ytsage_icons as icons
from . import ytsage_theme as theme
from .ytsage_player_input import SEEK_MEDIUM, build_context_menu, install_player_shortcuts
from ..core.ytsage_mpv import probe_player
from ..core.ytsage_yt_dlp import get_yt_dlp_path
from ..utils.ytsage_config_manager import ConfigManager
from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger
QUALITY_CHOICES = [
("player.quality_auto", None),
("2160p", 2160),
("1440p", 1440),
("1080p", 1080),
("720p", 720),
("480p", 480),
("360p", 360),
]
SPEED_CHOICES = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
def _ytdl_format_for(height: Optional[int]) -> str:
if height is None:
return "bestvideo+bestaudio/best"
return f"bestvideo[height<=?{height}]+bestaudio/best[height<=?{height}]"
def _build_ytdl_raw_options() -> str:
"""
Mirror the app's cookie/proxy config into ytdl_hook raw options.
mpv splits this option on commas, so any comma inside a value would end
the option and start a bogus one -- silently, and a Windows cookie path or
a proxy URL with credentials can easily contain one. mpv's own escape for
that is a backslash, so values are escaped rather than trusted.
"""
def esc(value) -> str:
return str(value).replace("\\", "\\\\").replace(",", "\\,")
opts = []
if ConfigManager.get("cookie_active"):
if ConfigManager.get("cookie_source") == "file":
path = ConfigManager.get("cookie_file_path")
if path:
opts.append(f"cookies={esc(path)}")
else:
browser = ConfigManager.get("cookie_browser")
profile = ConfigManager.get("cookie_browser_profile")
if browser:
value = f"{browser}:{profile}" if profile else browser
opts.append(f"cookies-from-browser={esc(value)}")
proxy = ConfigManager.get("proxy_url")
if proxy:
opts.append(f"proxy={esc(proxy)}")
# The downloader honours geo_proxy_url; the player was ignoring it, so a
# geo-restricted video downloaded but would not play.
geo_proxy = ConfigManager.get("geo_proxy_url")
if geo_proxy:
opts.append(f"geo-verification-proxy={esc(geo_proxy)}")
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.
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)
mpvDurationChanged = Signal(float)
mpvPausedChanged = Signal(bool)
mpvEndReached = Signal(str) # end-file reason
mpvError = Signal(str)
mpvMuteChanged = Signal(bool)
mpvVolumeChanged = Signal(float)
mpvBufferingChanged = Signal(bool)
_renderUpdateRequested = Signal()
# Mouse gestures, surfaced for PlayerPanel to act on. Kept here rather
# than on the panel so the control bar below the video is unaffected.
clicked = Signal()
doubleClicked = Signal()
wheelScrolled = Signal(int, bool) # notches, ctrl_held
mouseMovedOverVideo = Signal()
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(240)
# Without this the widget cannot hold focus, which is why the panel's
# key handler never ran.
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setMouseTracking(True)
self._wheel_remainder = 0
# A single click must not act until a double-click can be ruled out,
# or double-click-to-fullscreen also pauses on the way.
self._click_timer = QTimer(self)
self._click_timer.setSingleShot(True)
self._click_timer.timeout.connect(self.clicked)
self._mpv = None
self._render_ctx = None
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,
"keep_open": "yes",
"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":
kwargs["script_opts"] = f"ytdl_hook-ytdl_path={ytdlp_path}"
self._mpv = mpv.MPV(log_handler=self._on_mpv_log, **kwargs)
self._mpv["ytdl-format"] = _ytdl_format_for(ConfigManager.get("player.default_quality"))
raw_opts = _build_ytdl_raw_options()
if raw_opts:
self._mpv["ytdl-raw-options"] = raw_opts
self._mpv.observe_property("time-pos", self._on_time_pos)
self._mpv.observe_property("duration", self._on_duration)
self._mpv.observe_property("pause", self._on_pause)
# Observed rather than assumed, so the button follows changes made
# by a key, the menu or mpv itself.
self._mpv.observe_property("mute", self._on_mute)
self._mpv.observe_property("volume", self._on_volume)
# Buffering was entirely invisible: a stall showed a frozen frame for
# 25 seconds before the retry, with nothing on screen.
self._mpv.observe_property("paused-for-cache", self._on_cache_pause)
@self._mpv.event_callback("end-file")
def _on_end_file(event): # mpv thread
try:
reason = str(getattr(event.data, "reason", ""))
except Exception:
reason = ""
self.mpvEndReached.emit(reason)
# mpv-thread callbacks: signals only, no widget access
def _on_time_pos(self, _name, value) -> None:
if value is not None:
self.mpvPositionChanged.emit(float(value))
def _on_duration(self, _name, value) -> None:
if value is not None:
self.mpvDurationChanged.emit(float(value))
def _on_pause(self, _name, value) -> None:
if value is not None:
self.mpvPausedChanged.emit(bool(value))
def _on_mute(self, _name, value) -> None:
if value is not None:
self.mpvMuteChanged.emit(bool(value))
def _on_volume(self, _name, value) -> None:
if value is not None:
self.mpvVolumeChanged.emit(float(value))
def _on_cache_pause(self, _name, value) -> None:
if value is not None:
self.mpvBufferingChanged.emit(bool(value))
#: libmpv reports these at error level, but they are noise from the GL
#: driver rather than a failure: playback continues and frames keep
#: arriving. They were filling the error log with hundreds of lines per
#: session, which buries the errors that do matter.
_BENIGN_MPV_ERRORS = (
"after creating texture: OpenGL error INVALID_ENUM",
)
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
message = text.strip()
if level in ("error", "fatal"):
if level == "error" and any(noise in message for noise in self._BENIGN_MPV_ERRORS):
logger.debug(f"mpv [{prefix}] {message}")
return
logger.error(f"mpv [{prefix}] {message}")
if "ytdl" in prefix or level == "fatal":
self.mpvError.emit(message)
else:
logger.debug(f"mpv [{prefix}] {message}")
# --------------------------------------------------------------- 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:
return 0
address = glctx.getProcAddress(name if isinstance(name, bytes) else name.encode("utf-8"))
return int(address) if address else 0
# 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:
render_ctx = MpvRenderContext(
self._mpv,
"opengl",
opengl_init_params={"get_proc_address": proc_addr_fn},
)
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.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:
# 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)
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.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 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}")
# ----------------------------------------------------------------- mouse
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.setFocus(Qt.FocusReason.MouseFocusReason)
self._click_timer.start(QApplication.doubleClickInterval())
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self._click_timer.stop() # cancel the pending single-click
self.doubleClicked.emit()
super().mouseDoubleClickEvent(event)
def wheelEvent(self, event) -> None:
# Accumulate: a high-resolution trackpad sends far less than one
# notch (120 units) per event, and dropping those makes it inert.
self._wheel_remainder += event.angleDelta().y()
notches, self._wheel_remainder = divmod(abs(self._wheel_remainder), 120)
if notches:
sign = 1 if event.angleDelta().y() > 0 else -1
ctrl = bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier)
self.wheelScrolled.emit(sign * int(notches), ctrl)
event.accept()
def mouseMoveEvent(self, event) -> None:
self.mouseMovedOverVideo.emit()
super().mouseMoveEvent(event)
# ------------------------------------------------------------- controls
@property
def mpv(self):
return self._mpv
class PlayerPanel(QWidget):
"""Video area + transport controls. Public API: play/enqueue-agnostic."""
positionChanged = Signal(float)
durationChanged = Signal(float)
playbackEnded = Signal(str) # end-file reason ("eof", "error", ...)
playerError = Signal(str)
nowPlayingChanged = Signal(dict) # entry dict of the current item
# The panel stays queue-agnostic: it asks, WatchPage decides what is next.
nextRequested = Signal()
previousRequested = Signal()
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._current_entry: Dict[str, Any] = {}
self._duration: float = 0.0
self._slider_down = False
self._fullscreen_controller = None
self._muted = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
self.video = MpvRenderWidget(self)
layout.addWidget(self.video, stretch=1)
# A child of the video widget, not a sibling in the layout: it must
# float over the frame without changing the geometry underneath.
self.buffering_label = QLabel(_("player.buffering"), self.video)
self.buffering_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.buffering_label.setStyleSheet(
f"background-color: rgba(0,0,0,170); color: {theme.TEXT};"
"padding: 8px 16px; border-radius: 6px; font-size: 13px;"
)
self.buffering_label.hide()
self.title_label = QLabel("")
self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;")
self.title_label.setWordWrap(True)
layout.addWidget(self.title_label)
# A container, not a bare layout: fullscreen needs something it can
# hide, and hiding a layout is not a thing.
self.controls_bar = QWidget(self)
controls = QHBoxLayout(self.controls_bar)
controls.setSpacing(8)
controls.setContentsMargins(6, 0, 6, 4)
self.prev_btn = QPushButton()
self.prev_btn.setIcon(icons.icon("skip-back", theme.ICON_ON_ACCENT))
self.prev_btn.setFixedWidth(36)
self.prev_btn.setToolTip(_("player.previous"))
self.prev_btn.clicked.connect(self.request_previous)
controls.addWidget(self.prev_btn)
self.play_btn = QPushButton()
self.play_btn.setIcon(icons.icon("play", theme.ICON_ON_ACCENT))
self.play_btn.setFixedWidth(36)
self.play_btn.setToolTip(_("player.play_pause"))
self.play_btn.clicked.connect(self.toggle_pause)
controls.addWidget(self.play_btn)
self.next_btn = QPushButton()
self.next_btn.setIcon(icons.icon("skip-forward", theme.ICON_ON_ACCENT))
self.next_btn.setFixedWidth(36)
self.next_btn.setToolTip(_("player.next"))
self.next_btn.clicked.connect(self.request_next)
controls.addWidget(self.next_btn)
self.time_label = QLabel("0:00 / 0:00")
controls.addWidget(self.time_label)
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
self.seek_slider.setRange(0, 1000)
self.seek_slider.setToolTip(_("player.seek_tooltip"))
self.seek_slider.sliderPressed.connect(self._on_slider_pressed)
self.seek_slider.sliderReleased.connect(self._on_slider_released)
controls.addWidget(self.seek_slider, stretch=1)
self.quality_combo = QComboBox()
for label, height in QUALITY_CHOICES:
self.quality_combo.addItem(_(label) if label.startswith("player.") else label, height)
default_q = ConfigManager.get("player.default_quality")
idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0)
self.quality_combo.setCurrentIndex(idx)
self.quality_combo.setToolTip(_("player.quality_tooltip"))
self.quality_combo.currentIndexChanged.connect(self._on_quality_changed)
controls.addWidget(self.quality_combo)
self.speed_combo = QComboBox()
for s in SPEED_CHOICES:
self.speed_combo.addItem(f"{s:g}x", s)
self.speed_combo.setCurrentIndex(SPEED_CHOICES.index(1.0))
self.speed_combo.setToolTip(_("player.speed_tooltip"))
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
controls.addWidget(self.speed_combo)
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
self.volume_slider.setRange(0, 100)
self.volume_slider.setFixedWidth(90)
self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100))
self.volume_slider.setToolTip(_("player.volume_tooltip"))
self.volume_slider.valueChanged.connect(self._on_volume_changed)
self.mute_btn = QPushButton()
self.mute_btn.setIcon(icons.icon("volume-high", theme.ICON_ON_ACCENT))
self.mute_btn.setFixedWidth(36)
self.mute_btn.setToolTip(_("player.act_mute"))
self.mute_btn.clicked.connect(self.toggle_mute)
controls.addWidget(self.mute_btn)
controls.addWidget(self.volume_slider)
self.subs_btn = QPushButton(_("player.subtitles"))
self.subs_btn.setCheckable(True)
self.subs_btn.setToolTip(_("player.subtitles_tooltip"))
self.subs_btn.toggled.connect(self._on_subs_toggled)
controls.addWidget(self.subs_btn)
self.fullscreen_btn = QPushButton()
self.fullscreen_btn.setIcon(icons.icon("maximize", theme.ICON_ON_ACCENT))
self.fullscreen_btn.setFixedWidth(36)
self.fullscreen_btn.setToolTip(_("player.fullscreen"))
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
controls.addWidget(self.fullscreen_btn)
layout.addWidget(self.controls_bar)
# mpv-thread signals arrive queued on the GUI thread
self.video.mpvPositionChanged.connect(self._on_position, Qt.ConnectionType.QueuedConnection)
self.video.mpvDurationChanged.connect(self._on_duration, Qt.ConnectionType.QueuedConnection)
self.video.mpvPausedChanged.connect(self._on_paused_changed, Qt.ConnectionType.QueuedConnection)
self.video.mpvEndReached.connect(self._on_end_reached, Qt.ConnectionType.QueuedConnection)
self.video.mpvError.connect(self.playerError, Qt.ConnectionType.QueuedConnection)
self.video.mpvMuteChanged.connect(self._on_mute_changed, Qt.ConnectionType.QueuedConnection)
self.video.mpvVolumeChanged.connect(self._on_mpv_volume, Qt.ConnectionType.QueuedConnection)
self.video.mpvBufferingChanged.connect(self._on_buffering, Qt.ConnectionType.QueuedConnection)
self._volume_apply_timer = QTimer(self)
self._volume_apply_timer.setSingleShot(True)
self._volume_apply_timer.setInterval(400)
self._volume_apply_timer.timeout.connect(self._persist_volume)
# YouTube's CDN intermittently serves stalled/poisoned streams to
# non-browser clients; a reload re-resolves the URLs and usually
# lands on a healthy node. Retry automatically on startup stall.
self._stall_timer = QTimer(self)
self._stall_timer.setSingleShot(True)
self._stall_timer.setInterval(25000)
self._stall_timer.timeout.connect(self._on_startup_stall)
self._stall_retries = 0
self._playback_started = False
# --- input ------------------------------------------------------
# The panel must be able to hold focus for its shortcuts to fire...
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# ...and the control bar must not take it, or clicking play would
# park focus on a button that then swallows Space. This is why the
# old keyPressEvent never ran.
for widget in (
self.prev_btn, self.play_btn, self.next_btn, self.mute_btn,
self.subs_btn, self.fullscreen_btn, self.seek_slider,
self.volume_slider, self.quality_combo, self.speed_combo,
):
widget.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# Kept on the instance: an unreferenced QShortcut is collected and
# silently stops working.
self._shortcuts = install_player_shortcuts(self)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
self.video.clicked.connect(self.toggle_pause)
self.video.doubleClicked.connect(self.toggle_fullscreen)
self.video.wheelScrolled.connect(self._on_wheel)
self.video.mouseMovedOverVideo.connect(self._on_mouse_activity)
# Controls hide themselves in fullscreen only; doing it in a window
# would make the layout jump under the pointer.
self._idle_timer = QTimer(self)
self._idle_timer.setSingleShot(True)
self._idle_timer.setInterval(2500)
self._idle_timer.timeout.connect(self._hide_idle_controls)
# ------------------------------------------------------------ public API
def play(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
"""Play a video. entry needs at least {"url": ...}; extra keys
(id/title/channel/duration/thumbnail) travel to nowPlayingChanged."""
url = entry.get("url") or entry.get("webpage_url")
if not url:
self.playerError.emit("No playable URL in entry")
return
self._current_entry = dict(entry)
title = entry.get("title") or url
self.title_label.setText(title)
mpv_inst = self.video.mpv
if mpv_inst is None:
return
options = {}
if resume_pos and resume_pos > 0:
options["start"] = f"+{max(0.0, resume_pos - 5.0):.1f}"
try:
mpv_inst.loadfile(url, **options)
mpv_inst["pause"] = False
except Exception as e:
logger.exception(f"mpv loadfile failed: {e}")
self.playerError.emit(str(e))
return
self._playback_started = False
self._stall_timer.start()
self.nowPlayingChanged.emit(self._current_entry)
def _on_startup_stall(self) -> None:
if self._playback_started or not self._current_entry:
return
if self._stall_retries < 2:
self._stall_retries += 1
logger.warning(f"Stream stalled before starting; retrying ({self._stall_retries}/2)")
entry = self._current_entry
self._current_entry = {}
self.play(entry)
else:
self._stall_retries = 0
self.playerError.emit(_("player.stream_stalled"))
def stop(self) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst.command("stop")
except Exception:
pass
def toggle_pause(self) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["pause"] = not mpv_inst["pause"]
except Exception:
pass
# ------------------------------------------------------- bound actions
# Every method below is reachable from a key, the context menu, or both;
# see ytsage_player_input.ACTIONS. They are all no-ops when nothing is
# loaded rather than raising, because a key can be pressed at any time.
def seek_relative(self, seconds: float) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
mpv_inst.seek(seconds, reference="relative")
except Exception as e:
logger.debug(f"Relative seek failed: {e}")
def seek_absolute(self, seconds: float) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
mpv_inst.seek(max(0.0, seconds), reference="absolute")
except Exception as e:
logger.debug(f"Absolute seek failed: {e}")
def seek_percent(self, fraction: float) -> None:
if self._duration > 0:
self.seek_absolute(self._duration * max(0.0, min(1.0, fraction)))
def seek_to_end(self) -> None:
if self._duration > 0:
# Not exactly the end: mpv would treat that as EOF and advance.
self.seek_absolute(max(0.0, self._duration - 3.0))
def frame_step(self, direction: int) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
mpv_inst.command("frame-back-step" if direction < 0 else "frame-step")
except Exception as e:
logger.debug(f"Frame step failed: {e}")
def nudge_volume(self, delta: int) -> None:
self.volume_slider.setValue(max(0, min(100, self.volume_slider.value() + delta)))
def toggle_mute(self) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
mpv_inst["mute"] = not bool(mpv_inst["mute"])
except Exception as e:
logger.debug(f"Mute toggle failed: {e}")
def nudge_speed(self, delta: float) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
current = float(mpv_inst["speed"] or 1.0)
except Exception:
current = 1.0
self._apply_speed(max(0.25, min(4.0, round((current + delta) * 100) / 100)))
def reset_speed(self) -> None:
self._apply_speed(1.0)
def _apply_speed(self, speed: float) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["speed"] = speed
except Exception as e:
logger.debug(f"Speed change failed: {e}")
# Reflect it in the combo when it is one of the offered values,
# without re-triggering the handler.
index = self.speed_combo.findData(speed)
if index >= 0 and index != self.speed_combo.currentIndex():
self.speed_combo.blockSignals(True)
self.speed_combo.setCurrentIndex(index)
self.speed_combo.blockSignals(False)
def cycle_subtitles(self) -> None:
self.subs_btn.setChecked(not self.subs_btn.isChecked())
def request_next(self) -> None:
self.nextRequested.emit()
def request_previous(self) -> None:
self.previousRequested.emit()
def copy_video_url(self) -> None:
url = self._current_entry.get("url") or self._current_entry.get("webpage_url")
if url:
QApplication.clipboard().setText(str(url))
def open_in_browser(self) -> None:
url = self._current_entry.get("url") or self._current_entry.get("webpage_url")
if url:
webbrowser.open(str(url))
def reload_auth(self) -> None:
"""
Push changed cookie/proxy settings onto the live mpv handle.
These were read once, when the widget was built, so signing in only
reached playback after restarting the app. ytdl_hook consults the
option when it resolves a URL, so anything already playing keeps the
streams it resolved with -- it is reloaded at its current position.
"""
mpv_inst = self.video.mpv
if mpv_inst is None:
return
raw_opts = _build_ytdl_raw_options()
try:
mpv_inst["ytdl-raw-options"] = raw_opts
except Exception as e:
logger.debug(f"Could not update ytdl-raw-options: {e}")
return
logger.info("Player authentication settings reloaded.")
if self._current_entry and self._playback_started:
position = self.current_position()
entry = self._current_entry
self._current_entry = {}
self.play(entry, resume_pos=position)
def current_entry(self) -> Dict[str, Any]:
return dict(self._current_entry)
def current_position(self) -> float:
mpv_inst = self.video.mpv
try:
return float(mpv_inst["time-pos"] or 0.0) if mpv_inst else 0.0
except Exception:
return 0.0
def shutdown(self) -> None:
self.video.shutdown()
# ----------------------------------------------------------- slots (GUI)
@Slot(float)
def _on_position(self, pos: float) -> None:
if pos > 0 and not self._playback_started:
self._playback_started = True
self._stall_retries = 0
self._stall_timer.stop()
if not self._slider_down and self._duration > 0:
self.seek_slider.blockSignals(True)
self.seek_slider.setValue(int(pos / self._duration * 1000))
self.seek_slider.blockSignals(False)
self.time_label.setText(f"{_format_time(pos)} / {_format_time(self._duration)}")
self.positionChanged.emit(pos)
@Slot(float)
def _on_duration(self, duration: float) -> None:
self._duration = duration
self.durationChanged.emit(duration)
@Slot(bool)
def _on_paused_changed(self, paused: bool) -> None:
self.play_btn.setIcon(icons.icon("play" if paused else "pause", theme.ICON_ON_ACCENT))
self.play_btn.setToolTip(_("player.play") if paused else _("player.pause"))
@Slot(bool)
def _on_mute_changed(self, muted: bool) -> None:
self._muted = muted
self._refresh_volume_icon()
@Slot(float)
def _on_mpv_volume(self, volume: float) -> None:
# Reflect changes made by key or menu, without echoing back into mpv.
value = int(round(volume))
if value != self.volume_slider.value():
self.volume_slider.blockSignals(True)
self.volume_slider.setValue(value)
self.volume_slider.blockSignals(False)
self._volume_apply_timer.start()
self._refresh_volume_icon()
def _refresh_volume_icon(self) -> None:
if self._muted or self.volume_slider.value() == 0:
name = "volume-mute"
elif self.volume_slider.value() < 50:
name = "volume-low"
else:
name = "volume-high"
self.mute_btn.setIcon(icons.icon(name, theme.ICON_ON_ACCENT))
self.mute_btn.setToolTip(_("player.act_unmute") if self._muted else _("player.act_mute"))
@Slot(bool)
def _on_buffering(self, buffering: bool) -> None:
self.buffering_label.setVisible(buffering)
if buffering:
self.buffering_label.raise_()
@Slot(str)
def _on_end_reached(self, reason: str) -> None:
self.playbackEnded.emit(reason)
def _on_slider_pressed(self) -> None:
self._slider_down = True
def _on_slider_released(self) -> None:
self._slider_down = False
if self._duration > 0:
target = self.seek_slider.value() / 1000 * self._duration
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst.seek(target, reference="absolute")
except Exception as e:
logger.debug(f"Seek failed: {e}")
def _on_quality_changed(self, index: int) -> None:
height = self.quality_combo.itemData(index)
ConfigManager.set("player.default_quality", height)
mpv_inst = self.video.mpv
if mpv_inst is None:
return
mpv_inst["ytdl-format"] = _ytdl_format_for(height)
# Reload the current item at the new quality, keeping position. Only
# when something is actually loaded -- otherwise changing the default
# quality would start playing whatever was last selected.
if self._current_entry and self._playback_started:
pos = self.current_position()
self.play(self._current_entry, resume_pos=pos + 5.0 if pos else 0.0)
def _on_speed_changed(self, index: int) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["speed"] = self.speed_combo.itemData(index)
except Exception:
pass
def _on_volume_changed(self, value: int) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["volume"] = value
except Exception:
pass
self._volume_apply_timer.start()
def _persist_volume(self) -> None:
ConfigManager.set("player.volume", self.volume_slider.value())
def _on_subs_toggled(self, checked: bool) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["sid"] = "auto" if checked else "no"
except Exception:
pass
# ---------------------------------------------------------- mouse/idle
def _on_wheel(self, notches: int, ctrl_held: bool) -> None:
if ctrl_held:
self.seek_relative(notches * SEEK_MEDIUM)
else:
self.nudge_volume(notches * 2)
def _on_mouse_activity(self) -> None:
"""Any movement over the video brings the controls back."""
if not self.controls_bar.isVisible():
self.controls_bar.show()
self.video.unsetCursor()
if self.is_fullscreen():
self._idle_timer.start()
def _hide_idle_controls(self) -> None:
# Only in fullscreen, and never while the pointer is on the bar
# itself -- hiding it out from under the cursor is hostile.
if not self.is_fullscreen() or self.controls_bar.underMouse():
return
try:
if bool(self.video.mpv["pause"]):
return # paused: leave the controls up
except Exception:
pass
self.controls_bar.hide()
self.video.setCursor(Qt.CursorShape.BlankCursor)
def on_fullscreen_changed(self, active: bool) -> None:
"""Called by FullscreenController so idle-hiding follows the state."""
self.fullscreen_btn.setIcon(
icons.icon("minimize" if active else "maximize", theme.ICON_ON_ACCENT)
)
self.fullscreen_btn.setToolTip(
_("player.act_leave_fullscreen") if active else _("player.fullscreen")
)
if active:
self.setFocus(Qt.FocusReason.OtherFocusReason)
self._idle_timer.start()
else:
self._idle_timer.stop()
self.controls_bar.show()
self.video.unsetCursor()
def contextMenuEvent(self, event) -> None:
menu = build_context_menu(self)
menu.exec(event.globalPos())
event.accept()
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
# The buffering label floats over the video, so it has no layout to
# centre it. Positioned even while hidden, so it appears in the right
# place rather than jumping there.
hint = self.buffering_label.sizeHint()
self.buffering_label.setGeometry(
(self.video.width() - hint.width()) // 2,
(self.video.height() - hint.height()) // 2,
hint.width(),
hint.height(),
)
# ------------------------------------------------------------ fullscreen
def set_fullscreen_controller(self, controller) -> None:
"""
Injected by the main window, which owns the chrome being hidden.
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):
"""Placeholder shown when libmpv is missing; the rest of the app works."""
def __init__(self, hint: str, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.addStretch()
msg = QLabel(_("player.unavailable"))
msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
msg.setStyleSheet("font-size: 16px; font-weight: bold;")
layout.addWidget(msg)
hint_label = QLabel(hint)
hint_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint_label.setWordWrap(True)
layout.addWidget(hint_label)
layout.addStretch()
def create_player_panel(parent: Optional[QWidget] = None) -> QWidget:
"""PlayerPanel when libmpv is available, otherwise the hint placeholder."""
available, hint = probe_player()
if available:
return PlayerPanel(parent)
return PlayerUnavailablePanel(hint, parent)
def _format_time(seconds: float) -> str:
seconds = int(max(0, seconds))
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"