""" 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. """ from typing import Any, Dict, Optional from PySide6.QtCore import 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 ..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) class MpvRenderWidget(QOpenGLWidget): """QOpenGLWidget hosting a libmpv render context.""" # 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._renderUpdateRequested.connect(self.update, Qt.ConnectionType.QueuedConnection) self._create_mpv() # ------------------------------------------------------------------ mpv def _create_mpv(self) -> None: import mpv kwargs: Dict[str, Any] = { "vo": "libmpv", "ytdl": True, "keep_open": "yes", "idle": "yes", "osc": False, "input_default_bindings": False, } 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 initializeGL(self) -> None: from mpv import MpvGlGetProcAddressFn, MpvRenderContext 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 self._get_proc_address = MpvGlGetProcAddressFn(get_proc_address) try: self._render_ctx = MpvRenderContext( self._mpv, "opengl", opengl_init_params={"get_proc_address": self._get_proc_address}, ) 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}") def paintGL(self) -> None: if self._render_ctx is None: 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}, ) def shutdown(self) -> None: try: if self._render_ctx is not None: self._render_ctx.free() self._render_ctx = None 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}") # ------------------------------------------------------------- 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_holder: Optional[QWidget] = 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(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) self.play_btn.setFixedWidth(36) 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.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.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.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.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.toggled.connect(self._on_subs_toggled) controls.addWidget(self.subs_btn) self.fullscreen_btn = QPushButton() self.fullscreen_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarMaxButton)) self.fullscreen_btn.setFixedWidth(36) 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: icon = QStyle.StandardPixmap.SP_MediaPlay if paused else QStyle.StandardPixmap.SP_MediaPause self.play_btn.setIcon(self.style().standardIcon(icon)) @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 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 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) 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}"