From f8861258573070f241c2d12baddb0b17be21d3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 25 Jul 2026 01:44:25 +0200 Subject: [PATCH] Add embedded mpv player (render API + QOpenGLWidget) New ytsage/gui/ytsage_gui_player.py: - MpvRenderWidget hosts libmpv through MpvRenderContext in a QOpenGLWidget - works on native Wayland where wid-embedding cannot - with all mpv-thread callbacks marshalled to the GUI thread via queued signals only. - PlayerPanel adds transport controls: play/pause, seek slider, time display, quality selector (caps ytdl-format height and reloads in place), speed, volume (persisted), subtitle toggle, fullscreen (reparent to top-level window), and Space/F/Escape keys. - Playback resolves watch URLs through mpv's ytdl_hook pointed at the app-managed SHA256-verified yt-dlp binary (script-opts ytdl_hook-ytdl_path), inheriting cookies and proxy settings via ytdl-raw-options - stream freshness, DASH muxing and nsig handling stay in yt-dlp's hands. New ytsage/core/ytsage_mpv.py probes libmpv availability; without it the Watch UI shows a per-OS install hint and everything else works. python-mpv added to dependencies (libmpv itself is a system package). Config gains player.* and feed.* defaults. Verified on Wayland: real YouTube video streams with position/duration signals flowing and no thread-safety errors. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 1 + ytsage/core/ytsage_mpv.py | 38 ++ ytsage/gui/ytsage_gui_player.py | 504 ++++++++++++++++++++++++++ ytsage/languages/en.json | 9 + ytsage/utils/ytsage_config_manager.py | 11 + 5 files changed, 563 insertions(+) create mode 100644 ytsage/core/ytsage_mpv.py create mode 100644 ytsage/gui/ytsage_gui_player.py diff --git a/pyproject.toml b/pyproject.toml index 4da8f65..e763939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "markdown>=3.10", "loguru>=0.7.3", "setuptools>=80.9.0", + "python-mpv>=1.0.7", ] requires-python = ">=3.10,<3.15" readme = "README.md" diff --git a/ytsage/core/ytsage_mpv.py b/ytsage/core/ytsage_mpv.py new file mode 100644 index 0000000..4b0f44a --- /dev/null +++ b/ytsage/core/ytsage_mpv.py @@ -0,0 +1,38 @@ +""" +libmpv availability probe +========================= + +python-mpv is a ctypes binding: importing it raises OSError when the libmpv +shared library is missing from the system. All player code must import mpv +through probe_player() so the rest of the app (search, browse, downloads) +keeps working without libmpv installed. +""" + +from typing import Optional, Tuple + +from ..utils.ytsage_constants import OS_NAME +from ..utils.ytsage_logger import logger + +_probe_result: Optional[Tuple[bool, str]] = None + + +def probe_player() -> Tuple[bool, str]: + """Return (available, hint). hint explains how to install libmpv when absent.""" + global _probe_result + if _probe_result is not None: + return _probe_result + + try: + import mpv # noqa: F401 + + _probe_result = (True, "") + except (ImportError, OSError, AttributeError) as e: + logger.warning(f"libmpv unavailable, watch features disabled: {e}") + if OS_NAME == "Windows": + hint = "Place libmpv-2.dll next to the application, or install mpv and add it to PATH." + elif OS_NAME == "Darwin": + hint = "Install mpv with Homebrew: brew install mpv" + else: + hint = "Install mpv with your package manager, e.g. sudo pacman -S mpv or sudo apt install libmpv2" + _probe_result = (False, hint) + return _probe_result diff --git a/ytsage/gui/ytsage_gui_player.py b/ytsage/gui/ytsage_gui_player.py new file mode 100644 index 0000000..c3df031 --- /dev/null +++ b/ytsage/gui/ytsage_gui_player.py @@ -0,0 +1,504 @@ +""" +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) + + # ------------------------------------------------------------ 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.nowPlayingChanged.emit(self._current_entry) + + 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 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}" diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index b17e700..7bcd7bc 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -639,5 +639,14 @@ "update_success": "✅ Deno has been successfully updated!", "update_failed": "❌ Update failed: {error}", "check_failed": "Failed to check for updates. Please check your internet connection." + }, + "player": { + "quality_auto": "Auto", + "subtitles": "CC", + "unavailable": "Video player unavailable", + "now_playing": "Now Playing", + "queue": "Queue", + "play": "Play", + "pause": "Pause" } } \ No newline at end of file diff --git a/ytsage/utils/ytsage_config_manager.py b/ytsage/utils/ytsage_config_manager.py index af12b12..f698045 100644 --- a/ytsage/utils/ytsage_config_manager.py +++ b/ytsage/utils/ytsage_config_manager.py @@ -109,6 +109,17 @@ class ConfigManager: # the app-managed, SHA256-verified binary is absent "allow_system_ytdlp": False, }, + "player": { + "default_quality": 1080, # max stream height; None = auto/best + "volume": 100, + "resume": "auto", # auto | off + "source_mode": "ytdl", # ytdl (mpv ytdl_hook) | direct (raw stream URLs) + }, + "feed": { + "mode": "local", # local (per-channel aggregation) | account (cookies) + "per_channel_items": 15, + "auto_refresh_minutes": 0, # 0 = manual refresh only + }, } @classmethod