3aca43b372
There was no icon system at all. Transport buttons called QStyle.standardIcon(SP_MediaPlay), which returns the platform theme's dark monochrome glyph -- painted onto the app's saturated red buttons at a fixed 36px with no text, that is the black square. Everything else called an icon was an emoji baked into en.json, which is tofu wherever the emoji font is missing. Qt stylesheets cannot recolour a QIcon, so the colour is an argument to the new helper; that is the whole fix. The SVGs are drawn here rather than vendored, which keeps a third-party licence out of the tree, and they live in the module rather than as asset files, which keeps them out of package-data and safe in a frozen build. Tooltips were the other half: three widgets in the entire application had one and nothing set an accessible name. Filling that in at the call sites would have meant editing well over a hundred of them, most in upstream-owned files. Instead one application-level event filter handles QEvent.Polish, which Qt sends to every widget once before it is shown -- so it also reaches dialogs built by upstream code, and survives the next merge. It maps placeholder emoji to icons, fills empty tooltips from the button text, and logs icon-only buttons that still have none so the gaps are findable. StyleSheet.MAIN styles the window, inputs, buttons and tables and nothing else, so the main tab bar, combos, sliders, lists, menus, splitters, tooltips and the horizontal scrollbar fell through to the platform style. EXTRA_QSS covers them, in a fork-owned module appended at the one application site. SmoothTabWidget names its frame "tabContent" with the comment "We draw border on content instead" -- that rule existed only inside two dialogs, and now exists for the main window too. Two corrections to rules that were already there: the pressed style changed the padding, shifting every label two pixels and clipping fixed-width icon buttons, and checkboxes were fully rounded, which reads as a radio button rather than an on/off toggle. Verified by screenshot on the real display: tab bar, buttons, combo carets and checkboxes all render as intended, and all 35 icons rasterise non-empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
751 lines
28 KiB
Python
751 lines
28 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
|
|
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 (
|
|
QComboBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QPushButton,
|
|
QSizePolicy,
|
|
QSlider,
|
|
QStyle,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from . import ytsage_icons as icons
|
|
from . import ytsage_theme as theme
|
|
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."""
|
|
opts = []
|
|
if ConfigManager.get("cookie_active"):
|
|
if ConfigManager.get("cookie_source") == "file":
|
|
path = ConfigManager.get("cookie_file_path")
|
|
if path:
|
|
opts.append(f"cookies={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={value}")
|
|
proxy = ConfigManager.get("proxy_url")
|
|
if proxy:
|
|
opts.append(f"proxy={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)
|
|
_renderUpdateRequested = Signal()
|
|
|
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
super().__init__(parent)
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
self.setMinimumHeight(240)
|
|
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)
|
|
|
|
@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_mpv_log(self, level: str, prefix: str, text: str) -> None:
|
|
if level in ("error", "fatal"):
|
|
logger.error(f"mpv [{prefix}] {text.strip()}")
|
|
if "ytdl" in prefix or level == "fatal":
|
|
self.mpvError.emit(text.strip())
|
|
else:
|
|
logger.debug(f"mpv [{prefix}] {text.strip()}")
|
|
|
|
# --------------------------------------------------------------- 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}")
|
|
|
|
# ------------------------------------------------------------- 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
|
|
|
|
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
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(4)
|
|
|
|
self.video = MpvRenderWidget(self)
|
|
layout.addWidget(self.video, stretch=1)
|
|
|
|
self.title_label = QLabel("")
|
|
self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;")
|
|
self.title_label.setWordWrap(True)
|
|
layout.addWidget(self.title_label)
|
|
|
|
controls = QHBoxLayout()
|
|
controls.setSpacing(8)
|
|
controls.setContentsMargins(6, 0, 6, 4)
|
|
|
|
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.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)
|
|
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.addLayout(controls)
|
|
|
|
# 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._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
|
|
|
|
# ------------------------------------------------------------ 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
|
|
|
|
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(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
|
|
if self._current_entry:
|
|
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
|
|
|
|
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}"
|