From c0f1044144b09b40fc6e438bcaa0cd4ad0c9ebf8 Mon Sep 17 00:00:00 2001 From: NikkeDoy Date: Sun, 31 May 2026 23:03:55 +0300 Subject: [PATCH] :tada: | Project added --- .gitea/workflows/sonar.yaml | 25 + .gitignore | 3 + config.py | 98 ++ discord_rpc.py | 182 +++ gui.py | 2867 +++++++++++++++++++++++++++++++++++ main.py | 62 + music_db.py | 225 +++ player.py | 864 +++++++++++ requirements.txt | 4 + sonar-project.properties | 4 + 10 files changed, 4334 insertions(+) create mode 100644 .gitea/workflows/sonar.yaml create mode 100644 config.py create mode 100644 discord_rpc.py create mode 100644 gui.py create mode 100644 main.py create mode 100644 music_db.py create mode 100644 player.py create mode 100644 requirements.txt create mode 100644 sonar-project.properties diff --git a/.gitea/workflows/sonar.yaml b/.gitea/workflows/sonar.yaml new file mode 100644 index 0000000..6d0313f --- /dev/null +++ b/.gitea/workflows/sonar.yaml @@ -0,0 +1,25 @@ +name: SonarQube Code Quality Scan + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + +jobs: + sonarqube: + name: SonarQube Scan + runs-on: ubuntu-latest + + steps: + - name: Clone Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for advanced SonarQube features like blame info + + - name: Run SonarQube Scanner + uses: sonarsource/sonarqube-scan-action@v4 + env: + SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST_URL }} diff --git a/.gitignore b/.gitignore index 36b13f1..ffa2c70 100644 --- a/.gitignore +++ b/.gitignore @@ -171,6 +171,9 @@ cython_debug/ # Ruff stuff: .ruff_cache/ +# SonarQube +.scannerwork/ + # PyPI configuration file .pypirc diff --git a/config.py b/config.py new file mode 100644 index 0000000..14fe762 --- /dev/null +++ b/config.py @@ -0,0 +1,98 @@ +"""Tunetti configuration — stored at ~/.config/tunetti/config.json .""" + +import json +from pathlib import Path + +# ── Config file location (XDG compliant) ────────────────────────────────── +CONFIG_DIR = Path.home() / ".config" / "tunetti" +CONFIG_FILE = CONFIG_DIR / "config.json" + +# Legacy config in the project directory (for migration). +_LEGACY_FILE = Path(__file__).parent / "tunetti_config.json" + +DEFAULT_CONFIG: dict = { + # Playback + "volume": 50, + "max_history": 5000, + "ffplay_args": ["-nodisp", "-autoexit", "-loglevel", "quiet"], + # Database + "db_path": str(CONFIG_DIR / "music_history.db"), + # Discord + "discord_client_id": "1510266468388835328", + "discord_rpc_enabled": True, + # Visualizer + "visualizer_enabled": True, +} + + +def _migrate_legacy() -> None: + """Copy old project-level config and database to the new XDG location.""" + if CONFIG_FILE.exists(): + return # new config already exists — nothing to do + if not _LEGACY_FILE.exists(): + return # nothing to migrate + + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + try: + with open(_LEGACY_FILE) as f: + old = json.load(f) + + # Migrate the database file if it lived in the old project dir. + old_db_path = Path(old.get("db_path", "")) + if old_db_path.parent == Path(__file__).parent and old_db_path.exists(): + new_db_path = CONFIG_DIR / "music_history.db" + if not new_db_path.exists(): + import shutil + shutil.copy2(str(old_db_path), str(new_db_path)) + old["db_path"] = str(new_db_path) + + save_config({**DEFAULT_CONFIG, **old}) + # Rename legacy config so we don't re-migrate every launch. + _LEGACY_FILE.rename(_LEGACY_FILE.with_suffix(".json.migrated")) + except (json.JSONDecodeError, OSError): + pass + + +def load_config() -> dict: + _migrate_legacy() + + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE) as f: + return {**DEFAULT_CONFIG, **json.load(f)} + except (json.JSONDecodeError, OSError): + pass + + # First launch — create the directory and write defaults. + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + save_config(dict(DEFAULT_CONFIG)) + return dict(DEFAULT_CONFIG) + + +def save_config(config: dict) -> None: + """Merge *config* over defaults and persist to disk.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + merged = {**DEFAULT_CONFIG, **config} + with open(CONFIG_FILE, "w") as f: + json.dump(merged, f, indent=2) + + +def save_volume(volume: int) -> None: + """Persist current volume to disk and update in-memory SETTINGS.""" + volume = max(0, min(100, volume)) + SETTINGS["volume"] = volume + save_config(dict(SETTINGS)) + + +def save_setting(key: str, value) -> None: + """Persist a single setting and update the in-memory SETTINGS dict.""" + SETTINGS[key] = value + save_config(dict(SETTINGS)) + + +def get_setting(key: str): + """Return a setting value from the in-memory dict.""" + return SETTINGS.get(key, DEFAULT_CONFIG.get(key)) + + +SETTINGS = load_config() diff --git a/discord_rpc.py b/discord_rpc.py new file mode 100644 index 0000000..3973d14 --- /dev/null +++ b/discord_rpc.py @@ -0,0 +1,182 @@ +"""Discord Rich Presence integration for Tunetti.""" + +import logging +import threading +import time +from typing import Optional + +from pypresence import Presence, DiscordNotFound +from pypresence.types import ActivityType + +log = logging.getLogger("tunetti.rpc") + + +def _safe_artists(song: Optional[dict]) -> str: + """Extract a comma-separated artist string, handling None / missing keys.""" + if not song: + return "" + artists = song.get("artists") + if not isinstance(artists, list): + return "" + names = [] + for a in artists: + if isinstance(a, dict): + name = a.get("name", "") + if name: + names.append(name) + return ", ".join(names) + + +class DiscordRPC: + """Manages Discord Rich Presence for the currently playing song. + + Presence updates are normally batched every ``REFRESH_INTERVAL`` + seconds, but calling :meth:`update_song` immediately signals the + background loop to send the new state right away. + """ + + REFRESH_INTERVAL = 15 # seconds between re-sends + + def __init__(self, client_id: str): + self._client_id = client_id + self._rpc: Optional[Presence] = None + self._connected = False + self._song: Optional[dict] = None + self._start_ts: Optional[int] = None + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._wake_event = threading.Event() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + def start(self) -> None: + """Connect to Discord and start the background update loop.""" + if self._thread and self._thread.is_alive(): + return + self._stop_event.clear() + self._wake_event.clear() + self._thread = threading.Thread(target=self._run, daemon=True, + name="discord-rpc") + self._thread.start() + + def stop(self) -> None: + self._stop_event.set() + self._wake_event.set() + if self._thread: + self._thread.join(timeout=3) + self._disconnect() + + # ------------------------------------------------------------------ + # Public update + # ------------------------------------------------------------------ + def update_song(self, song: Optional[dict], + reset_start: bool = True) -> None: + """Set the currently playing song (or None to clear). + + When *reset_start* is ``True`` (default), the start timestamp + is set to now so Discord shows a fresh progress bar. Pass + ``False`` on resume to keep the original start time. + + The presence is sent to Discord *immediately*. + """ + with self._lock: + self._song = song + if reset_start: + self._start_ts = int(time.time()) if song else None + self._wake_event.set() + + def clear(self) -> None: + """Clear the presence entirely.""" + with self._lock: + self._song = None + self._start_ts = None + self._wake_event.set() + + def seek_to(self, position_ms: int) -> None: + """Recalculate start_ts after a seek so Discord's progress bar + shows the correct elapsed time from the new position.""" + with self._lock: + if self._start_ts is not None: + self._start_ts = int(time.time()) - (position_ms // 1000) + self._wake_event.set() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + def _connect(self) -> bool: + try: + self._rpc = Presence(self._client_id) + self._rpc.connect() + self._connected = True + log.info("Connected to Discord RPC") + return True + except DiscordNotFound: + log.warning("Discord not running – RPC unavailable") + self._connected = False + return False + except Exception as exc: + log.debug("Discord RPC connect failed: %s", exc) + self._connected = False + return False + + def _disconnect(self) -> None: + if self._rpc: + try: + self._rpc.close() + except Exception: + pass + self._rpc = None + self._connected = False + + def _send_presence(self) -> None: + with self._lock: + song = self._song + start = self._start_ts + + if not song: + if self._connected and self._rpc is not None: + try: + self._rpc.clear() + except Exception: + self._connected = False + return + + artists_str = _safe_artists(song) + title = song.get("title", "Unknown") + thumbnail = song.get("thumbnail") or "" + duration = song.get("duration", 0) + + end_ts = (start + duration) if start and duration else None + + if self._rpc is None: + return + + try: + self._rpc.update( + activity_type=ActivityType.LISTENING, + details=title, + state=f"by {artists_str}" if artists_str else "YouTube Music", + large_image=thumbnail, + start=start, + end=end_ts, + ) + except Exception as exc: + log.debug("RPC update failed: %s", exc) + self._connected = False + + def _run(self) -> None: + while not self._stop_event.is_set(): + if not self._connected: + self._connect() + if not self._connected: + self._stop_event.wait(10) + continue + + self._send_presence() + # Wait for either the refresh interval or an immediate wake-up + self._wake_event.wait(self.REFRESH_INTERVAL) + self._wake_event.clear() + + self._disconnect() diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..334a24e --- /dev/null +++ b/gui.py @@ -0,0 +1,2867 @@ +"""Tunetti – Glassmorphism YouTube Music Player. + +A reimagined, visually stunning interface with frosted-glass panels +and a focus on album art. +""" + +import json +import logging +import sys +import math +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import ( + Qt, QTimer, QThread, Signal, Slot, QRectF, QUrl, + QPropertyAnimation, QEasingCurve, QLoggingCategory, QEvent, +) +from PySide6.QtGui import ( + QFont, QPixmap, QPainter, QColor, QBrush, +) +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from PySide6.QtWidgets import ( + QApplication, QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, + QListWidget, QListWidgetItem, QMainWindow, QMenu, QPushButton, + QScrollArea, QSlider, QStackedWidget, QVBoxLayout, QWidget, QFrame, + QToolButton, QButtonGroup, QSizePolicy, +) +from PySide6.QtMultimedia import ( + QAudioBufferOutput, +) + +from config import SETTINGS, get_setting, save_setting, save_volume +from discord_rpc import DiscordRPC +from music_db import MusicDB +from player import AudioPlayer, SearchWorker, _best_thumbnail + +log = logging.getLogger("tunetti.gui") + +FONT = QFont("Cantarell, Noto Sans, Segoe UI, sans-serif", 10) + +# ── Re-used style constants ────────────────────────────────────────────── +_STYLE_BG_TRANSPARENT = "background: transparent;" +_STYLE_HEADER_WHITE = "color: #ffffff; padding-bottom: 4px;" +_STYLE_NOT_PLAYING = "Not playing" +_STYLE_FAV_HOVER = "QPushButton:hover { background: rgba(255,255,255,0.1); }" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _fmt_ms(ms: int) -> str: + total_sec = ms // 1000 + m, s = divmod(total_sec, 60) + return f"{m}:{s:02d}" + + +def _artists_str(artists) -> str: + if not artists or not isinstance(artists, list): + return "" + return ", ".join( + a.get("name", "") for a in artists if isinstance(a, dict) and a.get("name") + ) + + +def _norm_artists(artists_val) -> list: + if not isinstance(artists_val, list): + return [] + return [a for a in artists_val if isinstance(a, dict)] + + +def _css(*parts: str) -> str: + """Join CSS rule strings with spaces.""" + return "".join(parts) + + +# Shared thumbnail downloader to avoid spawning many QNAM instances +_thumbnail_nam: Optional[QNetworkAccessManager] = None + + +THUMB_CACHE_DIR = Path.home() / ".cache" / "tunetti" + + +def _get_thumbnail_nam() -> QNetworkAccessManager: + """Return a shared QNetworkAccessManager for thumbnail downloads.""" + global _thumbnail_nam + if _thumbnail_nam is None: + _thumbnail_nam = QNetworkAccessManager() + return _thumbnail_nam + + +def _cached_thumb_path(video_id: str) -> Path: + """Return the filesystem path for a cached thumbnail.""" + return THUMB_CACHE_DIR / f"{video_id}.jpg" + + +def _load_thumbnail(video_id: str, thumb_url: str, + art_label: QLabel, size: int, + style_pass: str, style_fail: str) -> None: + """Load a thumbnail into *art_label*, using ``~/.cache/tunetti/``. + + If the image is already cached it is loaded synchronously (local file IO + is fast enough). Otherwise a network request is dispatched, the result + is saved to cache, and then applied to the label. + """ + THUMB_CACHE_DIR.mkdir(parents=True, exist_ok=True) + cache_path = _cached_thumb_path(video_id) + + # ── Cache hit – load straight from disk ── + if cache_path.exists(): + pm = QPixmap() + if pm.load(str(cache_path)): + scaled = pm.scaled(size, size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + art_label.setPixmap(scaled) + art_label.setStyleSheet(style_pass) + return + + # ── Cache miss – download then cache ── + nam = _get_thumbnail_nam() + reply = nam.get(QNetworkRequest(QUrl(thumb_url))) + + def _on_downloaded(): + # The label may have been deleted (e.g. search results refreshed) + # while the network request was in flight — guard against that. + try: + if reply.error() != QNetworkReply.NetworkError.NoError: + art_label.setStyleSheet(style_fail) + return + data = reply.readAll() + pm = QPixmap() + if pm.loadFromData(data): + # Persist to cache + try: + with open(cache_path, "wb") as f: + f.write(data.data()) + except OSError: + pass + scaled = pm.scaled(size, size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + art_label.setPixmap(scaled) + art_label.setStyleSheet(style_pass) + else: + art_label.setStyleSheet(style_fail) + except RuntimeError: + pass # label was deleted before the download finished + finally: + reply.deleteLater() + + reply.finished.connect(_on_downloaded) + + +# ════════════════════════════════════════════════════════════════════════════ +# Glass card – frosted-glass panel helper +# ════════════════════════════════════════════════════════════════════════════ + + +class GlassCard(QFrame): + """Frosted-glass card with optional colored glow. + + Shadows are drawn manually in paintEvent rather than using + QGraphicsDropShadowEffect, which gets clipped on transparent widgets. + """ + + def __init__(self, parent=None, glow_color=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self._glow_color = glow_color + self._transparency = 60 # rgba alpha + + # Shadow parameters (drawn manually in paintEvent) + self._shadow_blur = 30 + self._shadow_alpha = 40 + self._shadow_dx = 0 + self._shadow_dy = 4 + + def _install_shadow(self, blur=20, alpha=30, dx=0, dy=4): + self._shadow_blur = blur + self._shadow_alpha = alpha + self._shadow_dx = dx + self._shadow_dy = dy + + def set_transparency(self, alpha: int) -> None: + self._transparency = max(0, min(255, alpha)) + + def paintEvent(self, event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + rect = self.rect().adjusted(1, 1, -1, -1) + radius = 16 + + # Skip expensive multi-pass shadow rendering when minimised + w = self.window() + is_minimized = w is not None and (w.isMinimized() or w.isHidden()) + + # Draw manual drop shadow (offset, blurred, below the card) + if self._shadow_alpha > 0 and not is_minimized: + shadow_rect = rect.translated(self._shadow_dx, self._shadow_dy) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(QColor(0, 0, 0, self._shadow_alpha))) + # Draw multiple offset passes to approximate blur + for i in range(1, max(1, self._shadow_blur // 6)): + offset = i * 2 + sr = shadow_rect.adjusted(-offset, -offset, offset, offset) + blur_alpha = max(1, self._shadow_alpha - i * 3) + p.setBrush(QBrush(QColor(0, 0, 0, blur_alpha))) + p.drawRoundedRect(sr, radius + offset, radius + offset) + + # Glass fill + if isinstance(self._glow_color, tuple): + r, g, b = self._glow_color + fill = QColor(r, g, b, self._transparency) + elif isinstance(self._glow_color, QColor): + fill = QColor(self._glow_color) + fill.setAlpha(self._transparency) + else: + fill = QColor(255, 255, 255, self._transparency) + + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(fill)) + p.drawRoundedRect(rect, radius, radius) + p.end() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Navigation button +# ═══════════════════════════════════════════════════════════════════════════════ + + +class NavButton(QToolButton): + """Navigation button with icon emoji and optional label text. + + When collapsed, only the icon emoji is shown and a tooltip + appears on hover. When expanded, the full label is displayed. + """ + + def __init__(self, icon: str, label: str, index: int, parent=None): + super().__init__(parent) + self._icon = icon + self._label = label + self._index = index + self._show_label = True + + self.setCheckable(True) + self.setToolTip(label) + self.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.setFixedHeight(44) + self._update_text() + + self.setStyleSheet(""" + NavButton { + border: none; + border-radius: 10px; + color: #aaaacc; + font-size: 10pt; + background: transparent; + text-align: left; + padding: 0 14px; + } + NavButton:hover { + background: rgba(139, 92, 246, 0.12); + color: #ffffff; + } + NavButton:checked { + background: rgba(139, 92, 246, 0.30); + color: #ffffff; + font-weight: bold; + } + """) + + def _update_text(self) -> None: + if self._show_label: + self.setText(f"{self._icon} {self._label}") + else: + self.setText(self._icon) + + def show_label(self, visible: bool) -> None: + """Toggle label visibility (collapse / expand).""" + self._show_label = visible + self._update_text() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Song list item widget +# ═══════════════════════════════════════════════════════════════════════════════ + + +class SongItemWidget(QWidget): + """A single row in the song list – glass card style.""" + + play_requested = Signal(str) + fav_toggled = Signal(str, bool) + queue_next_requested = Signal(str) + + def __init__(self, song: dict, is_fav: bool = False, parent=None): + super().__init__(parent) + self._video_id = song.get("videoId", "") + self._song = song + self._is_fav = is_fav + self._hovered = False + self.setMouseTracking(True) + self.setAttribute(Qt.WidgetAttribute.WA_Hover, True) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_context_menu) + + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 12, 16, 12) + layout.setSpacing(16) + + # Album art placeholder (dark box with musical note) + self._art = QLabel("") + self._art.setFixedSize(56, 56) + self._art.setStyleSheet("background-color: #1a1530; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05);") + self._art.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._art.setFont(QFont("Segoe UI Emoji", 20)) + self._art.setText("♪") + self._art.setStyleSheet("background-color: #1a1530; border-radius: 8px; color: #555; font-size: 20px; border: 1px solid rgba(255,255,255,0.05);") + layout.addWidget(self._art) + + # Title + artists + info = QWidget() + info_layout = QVBoxLayout(info) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(2) + + title_lbl = QLabel(song.get("title", "Unknown")) + title_lbl.setCursor(Qt.CursorShape.PointingHandCursor) + title_lbl.setStyleSheet("color: #e0d8f0; font-size: 11pt; font-weight: bold;") + info_layout.addWidget(title_lbl) + + artists_lbl = QLabel(_artists_str(song.get("artists"))) + artists_lbl.setCursor(Qt.CursorShape.PointingHandCursor) + artists_lbl.setStyleSheet("color: #8888aa; font-size: 9pt;") + info_layout.addWidget(artists_lbl) + + layout.addWidget(info, 1) + + # Fav button + self._fav_btn = QPushButton("★" if is_fav else "☆") + self._fav_btn.setFixedSize(24, 24) + fav_style = _css( + "QPushButton { border: none; border-radius: 12px; background: transparent; " + "color: #f1c40f; font-size: 12pt; }", + _STYLE_FAV_HOVER, + ) if is_fav else _css( + "QPushButton { border: none; border-radius: 12px; background: transparent; " + "color: #555577; font-size: 12pt; }", + _STYLE_FAV_HOVER, + ) + self._fav_btn.setStyleSheet(fav_style) + self._fav_btn.clicked.connect(self._toggle_fav) + layout.addWidget(self._fav_btn) + + # Load cached thumbnails instantly; defer network requests to avoid + # flooding the connection when many items appear at once (e.g. search). + # Guard with try/except because the widget may be deleted before the + # timer fires (search results refreshed, etc.). + if _cached_thumb_path(self._video_id).exists(): + self._load_art(self._art, song) + else: + QTimer.singleShot(500, lambda: self._deferred_load_art(song)) + + def _deferred_load_art(self, song: dict) -> None: + try: + self._load_art(self._art, song) + except RuntimeError: + pass # widget was deleted + + def _load_art(self, art_label: QLabel, song: dict): + """Load thumbnail from cache or network.""" + thumb_url = _best_thumbnail(song.get("thumbnails") or song.get("thumbnail")) + if not thumb_url: + return + _load_thumbnail( + self._video_id, thumb_url, art_label, + size=56, + style_pass="border-radius: 8px; background: transparent;", + style_fail="background-color: #1a1530; border-radius: 8px;", + ) + + def enterEvent(self, event): + self._hovered = True + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self._hovered = False + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + if self._hovered: + p.setBrush(QBrush(QColor(255, 255, 255, 10))) + p.setPen(Qt.PenStyle.NoPen) + p.drawRoundedRect(self.rect(), 10, 10) + p.end() + super().paintEvent(event) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.play_requested.emit(self._video_id) + super().mousePressEvent(event) + + def _toggle_fav(self): + self._is_fav = not self._is_fav + self._fav_btn.setText("★" if self._is_fav else "☆") + fav_style = _css( + "QPushButton { border: none; border-radius: 12px; background: transparent; " + "color: #f1c40f; font-size: 12pt; }", + _STYLE_FAV_HOVER, + ) if self._is_fav else _css( + "QPushButton { border: none; border-radius: 12px; background: transparent; " + "color: #555577; font-size: 12pt; }", + _STYLE_FAV_HOVER, + ) + self._fav_btn.setStyleSheet(fav_style) + self.fav_toggled.emit(self._video_id, self._is_fav) + + def _show_context_menu(self, pos): + menu = QMenu(self) + menu.setStyleSheet(""" + QMenu { + background: #1a1530; + border: 1px solid rgba(139, 92, 246, 0.25); + border-radius: 8px; + padding: 4px; + } + QMenu::item { + padding: 8px 24px; + border-radius: 6px; + color: #e0d8f0; + font-size: 9pt; + } + QMenu::item:selected { + background: rgba(139, 92, 246, 0.20); + color: #ffffff; + } + """) + + play_action = menu.addAction("▶ Play") + play_next_action = menu.addAction("⏭ Play Next") + + action = menu.exec(self.mapToGlobal(pos)) + if action == play_action: + self.play_requested.emit(self._video_id) + elif action == play_next_action: + self.queue_next_requested.emit(self._video_id) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Audio Visualizer – mirrored spectrum (Bass ↔ Highs ↔ Bass) +# ═══════════════════════════════════════════════════════════════════════════════ + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Optimised radix-2 FFT for audio spectrum analysis +# ═══════════════════════════════════════════════════════════════════════════════ + + +def _fft_magnitudes(samples: list[float]) -> list[float]: + """Radix-2 Cooley–Tukey FFT for real-valued input. + + Returns magnitude spectrum for frequencies DC .. Nyquist (n//2 + 1 bins). + Complexity O(n log n) — dramatically faster than the naive O(n²) DFT for + the 2048-sample frames used by the visualizer. + """ + n = len(samples) + + # ── Pad to next power of two ── + n2 = 1 + while n2 < n: + n2 <<= 1 + + real = samples + [0.0] * (n2 - n) + imag = [0.0] * n2 + + # ── Bit-reversal permutation ── + j = 0 + for i in range(1, n2): + bit = n2 >> 1 + while j & bit: + j ^= bit + bit >>= 1 + j ^= bit + if i < j: + real[i], real[j] = real[j], real[i] + + # ── Iterative butterfly ── + length = 2 + while length <= n2: + half = length >> 1 + angle = -2.0 * math.pi / length + w_real = math.cos(angle) + w_imag = math.sin(angle) + for i in range(0, n2, length): + wr, wi = 1.0, 0.0 + for k in range(half): + even = i + k + odd = i + k + half + # imag[even/odd] may be 0 on first pass but accumulate on + # later stages — use the general form. + tr = wr * real[odd] - wi * imag[odd] + ti = wr * imag[odd] + wi * real[odd] + real[odd] = real[even] - tr + imag[odd] = imag[even] - ti + real[even] = real[even] + tr + imag[even] = imag[even] + ti + wr, wi = wr * w_real - wi * w_imag, wr * w_imag + wi * w_real + length <<= 1 + + # ── Magnitudes (positive frequencies only) ── + half = n2 // 2 + mags = [0.0] * (half + 1) + for i in range(half + 1): + mags[i] = math.sqrt(real[i] * real[i] + imag[i] * imag[i]) + + return mags + + +class AudioVisualizer(QWidget): + """Mirrored-frequency audio visualizer — 60+ fps, minimised-aware. + + Renders real audio buffer data decomposed into frequency bands with a + smooth mirrored spectrum layout: + + Bass → LowMid → Mid → HighMid → Highs → HighMid → Mid → LowMid → Bass + + Features + -------- + * 16.7 ms timer (~60 fps) with Qt.PreciseTimer for vsync-aligned updates. + * Automatically suspends rendering when the parent window is minimised or + hidden, saving GPU/CPU cycles. + * Pre-computed colour gradient LUT to avoid per-frame interpolation. + * Idle "breathing" animation when audio is playing but no buffer arrives. + """ + + # Windows XP marquee loading pattern — sliding 4-bar block + _LOADING_PATTERN = [0.55, 0.85, 0.85, 0.55] + + # Number of vertical bars rendered + BAR_COUNT = 256 + + # Timer interval ≈ 60 fps (1000 / 60 = 16.667) + _TICK_MS = 16 + + # Spectral gradient colour stops (left → right / bass → treble) + SPECTRUM_COLORS = [ + QColor(139, 92, 246), # 0.0 – Violet (bass) + QColor(168, 85, 247), # 0.1 – Purple + QColor(236, 72, 153), # 0.25 – Pink + QColor(251, 146, 134), # 0.4 – Rose + QColor(251, 207, 132), # 0.5 – Amber (centre) + QColor(251, 207, 132), # 0.6 – Amber + QColor(236, 72, 153), # 0.75 – Pink + QColor(168, 85, 247), # 0.9 – Purple + QColor(139, 92, 246), # 1.0 – Violet (bass mirror) + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + self._values = [0.0] * self.BAR_COUNT + self._target_values = [0.0] * self.BAR_COUNT + self._is_playing = False + self._is_loading = False + self._loading_offset = 0.0 + self._loading_direction = 1 + self._is_minimized = False + self._last_activity = 0 + self._idle_phase = 0.0 + + # ── Pre-compute colour gradient as a fixed LUT ── + self._colour_lut = [ + self._spectrum_color(i / max(self.BAR_COUNT - 1, 1)) + for i in range(self.BAR_COUNT) + ] + + # ── High-resolution timer (~62.5 fps nominal) ── + self._timer = QTimer(self) + self._timer.timeout.connect(self._animate) + self._timer.setTimerType(Qt.TimerType.PreciseTimer) + self._timer.start(self._TICK_MS) + + # ── Public API ────────────────────────────────────────────────────────── + + def set_playing(self, playing: bool) -> None: + """Enable / disable visualisation (called by PlaybackBar).""" + self._is_playing = playing + if playing: + self._last_activity = 0 + + def set_loading(self, loading: bool) -> None: + """Enable / disable Windows XP marquee loading animation.""" + self._is_loading = loading + if loading: + self._loading_offset = 0.0 + self._loading_direction = 1 + self._last_activity = 0 + + def is_active(self) -> bool: + return self._is_playing + + def get_bar_value(self, index: int) -> float: + if 0 <= index < len(self._values): + return self._values[index] + return 0.0 + + def set_frequency_data(self, bar_magnitudes: list[float]) -> None: + """Push per-band magnitudes (left half of the spectrum). + + Fewer bands than BAR_COUNT are interpolated across the full + visualizer width so every bar carries a unique interpolated + value. The result is mirrored to create a symmetric display. + + Silently drops data when minimised. + """ + if not self._is_playing or self._is_minimized: + return + + num_bands = len(bar_magnitudes) + if num_bands == 0: + return + + total = self.BAR_COUNT + half_count = total // 2 + + # Interpolate fewer bands across the left half of bars + scale = (num_bands - 1) / max(half_count - 1, 1) + for i in range(half_count): + idx_f = i * scale + idx = int(idx_f) + frac = idx_f - idx + if idx >= num_bands - 1: + self._target_values[i] = bar_magnitudes[num_bands - 1] + else: + v = bar_magnitudes[idx] * (1.0 - frac) + bar_magnitudes[idx + 1] * frac + self._target_values[i] = v + + # Mirror the left half to the right half for symmetry + for i in range(half_count, total): + self._target_values[i] = self._target_values[total - 1 - i] + + self._last_activity = 0 + + # ── Window-state tracking ────────────────────────────────────────────── + + def set_minimized(self, minimized: bool) -> None: + """Called by the parent TunettiWindow when the window is minimised + or restored. Controls the animation timer so it stops firing + entirely when nobody is looking.""" + self._is_minimized = minimized + if minimized: + if self._timer.isActive(): + self._timer.stop() + else: + if not self._timer.isActive(): + self._timer.start(self._TICK_MS) + + def _check_minimized(self) -> None: + """Belt-and-suspenders check on every animation tick. + + Only updates the flag; does NOT start/stop the timer — that is + done by ``set_minimized()``, driven by TunettiWindow's + ``changeEvent`` and ``_poll_minimized`` fallback. + Stopping the timer here would prevent the main poll from + detecting a restore. + """ + w = self.window() + if w is not None: + self._is_minimized = w.isMinimized() or w.isHidden() + + # ── Animation tick ───────────────────────────────────────────────────── + + def _animate(self) -> None: + """Per-bar interpolation with asymmetric attack/decay. + + Each bar tracks its own target independently — fast attack (0.50) + and slower decay (0.16) create a bouncy, natural spectrum that + reacts instantly to transients but fades smoothly. + """ + self._check_minimized() + + if self._is_minimized: + return + + if self._is_loading: + self._update_loading_marquee() + self.update() + return + + self._idle_phase += 0.033 + + if not self._is_playing: + self._update_stopped_decay() + else: + self._update_active_decay() + + self.update() + + def _update_loading_marquee(self) -> None: + block = self._LOADING_PATTERN + blen = len(block) + max_offset = self.BAR_COUNT - blen + self._loading_offset += 0.6 * self._loading_direction + if self._loading_offset >= max_offset: + self._loading_offset = max_offset + self._loading_direction = -1 + elif self._loading_offset <= 0: + self._loading_offset = 0 + self._loading_direction = 1 + off = int(self._loading_offset) + for i in range(self.BAR_COUNT): + idx = i - off + if 0 <= idx < blen: + self._values[i] = block[idx] + else: + self._values[i] = 0.0 + + def _update_stopped_decay(self) -> None: + for i in range(self.BAR_COUNT): + self._values[i] *= 0.90 + if self._values[i] < 0.004: + self._values[i] = 0.0 + + def _update_active_decay(self) -> None: + self._last_activity += 1 + for i in range(self.BAR_COUNT): + target = self._target_values[i] + current = self._values[i] + + if target > 0.006: + # Active — asymmetric lerp per bar + diff = target - current + if diff > 0: + current += diff * 0.50 # fast attack (snappy) + else: + current += diff * 0.16 # slower decay (smooth) + self._values[i] = current + self._last_activity = 0 + elif self._last_activity < 12: + # Transitional — ease toward zero + self._values[i] *= 0.92 + elif self._last_activity >= 24: + # Idle breathing — low-amplitude sine wave + self._values[i] = ( + 0.05 + 0.04 * math.sin(self._idle_phase + i * 0.049) + ) + + # ── Painting ─────────────────────────────────────────────────────────── + + def paintEvent(self, event) -> None: + if self._is_minimized: + return + + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + w = self.width() + h = self.height() + bar_count = self.BAR_COUNT + bar_width = w / bar_count + + # ── Loading marquee — Windows XP green bars ── + if self._is_loading: + gap = 1.5 + dark = QColor(45, 125, 45) + light = QColor(120, 210, 100) + for i in range(bar_count): + val = self._values[i] + if val < 0.004: + continue + bar_height = val * h * 0.22 + x = i * bar_width + gap * 0.5 + bar_w = bar_width - gap + y = h - bar_height + colour = light if val > 0.7 else dark + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(colour)) + radius = min(bar_w * 0.5, bar_height * 0.5, 4.0) + p.drawRoundedRect(QRectF(x, y, bar_w, bar_height), radius, radius) + p.end() + return + + # ── Normal spectrum rendering ── + gap = 1.5 + + for i in range(bar_count): + val = self._values[i] + if val < 0.004: + continue + + bar_height = val * h * 0.95 + x = i * bar_width + gap * 0.5 + bar_w = bar_width - gap + y = h - bar_height + + colour = self._colour_lut[i] + alpha = min(255, int(val * 255) + 45) + draw_colour = QColor(colour) + draw_colour.setAlpha(alpha) + + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(draw_colour)) + + radius = min(bar_w * 0.5, bar_height * 0.5, 4.0) + p.drawRoundedRect(QRectF(x, y, bar_w, bar_height), radius, radius) + + # Glow cap on tall bars + if val > 0.35: + glow_alpha = min(255, int((val - 0.35) * 280)) + glow = QColor(colour) + glow.setAlpha(glow_alpha) + p.setBrush(QBrush(glow)) + dot_y = max(y + 1.0, y + bar_height * 0.02) + p.drawRoundedRect( + QRectF(x + bar_w * 0.5 - 1.5, dot_y, 3.0, 3.0), 2.0, 2.0, + ) + + p.end() + + # ── Helpers ──────────────────────────────────────────────────────────── + + @staticmethod + def _spectrum_color(t: float) -> QColor: + cols = AudioVisualizer.SPECTRUM_COLORS + if t <= 0.0: + return cols[0] + if t >= 1.0: + return cols[-1] + t_scaled = t * (len(cols) - 1) + idx = int(t_scaled) + frac = t_scaled - idx + c1 = cols[idx] + c2 = cols[min(idx + 1, len(cols) - 1)] + return QColor( + int(c1.red() + (c2.red() - c1.red()) * frac), + int(c1.green() + (c2.green() - c1.green()) * frac), + int(c1.blue() + (c2.blue() - c1.blue()) * frac), + int(c1.alpha() + (c2.alpha() - c1.alpha()) * frac), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Playback bar – blurred glass with visualizer on top +# ═══════════════════════════════════════════════════════════════════════════════ + + +class PlaybackBar(QWidget): + """Spotify-inspired bottom bar. + + ┌──────────────────────────────────────────────────────────────┐ + │ ████████████████████ ← 4px animated spectrum strip │ │ 90px + │ ┌──────────────────────────────────────────────────────────┐ │ + │ │ [🖼️] Title Artist ⏮ ▶ ⏭ 🔊 ────░░░ │ │ 86px + │ │ ──────────────────── │ │ + │ └──────────────────────────────────────────────────────────┘ │ + └──────────────────────────────────────────────────────────────┘ + + Three clear sections: + Left – Album art (56×56) + song title / artist + Center – Compact control row + slim progress bar + Right – Volume icon + slider + """ + + def __init__(self, player: AudioPlayer, visualizer: AudioVisualizer, + parent=None): + super().__init__(parent) + self._player = player + self._visualizer = visualizer + self._duration_ms = 0 + self._updating_progress = False + self._prev_vol = 50 + + self.setFixedHeight(86) # glass bar only — visualizer is a sibling + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + # ── Main layout ── + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # ── Glass bar (main body) ── + self._glass_bar = GlassCard(self, glow_color=(20, 15, 40)) + self._glass_bar.set_transparency(75) + self._glass_bar.setStyleSheet(""" + GlassCard { background: transparent; border: none; } + """) + + bar_layout = QHBoxLayout(self._glass_bar) + bar_layout.setContentsMargins(16, 0, 16, 0) + bar_layout.setSpacing(12) + + # ═══════════════════════════════════════════════════════════════ + # LEFT — Album art + song info + # ═══════════════════════════════════════════════════════════════ + left_widget = QWidget() + left_widget.setStyleSheet(_STYLE_BG_TRANSPARENT) + left_widget.setFixedWidth(300) + left_layout = QHBoxLayout(left_widget) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.setSpacing(14) + + self._np_art = QLabel() + self._np_art.setFixedSize(56, 56) + self._np_art.setStyleSheet( + "background: rgba(0,0,0,0.3); border-radius: 4px;" + ) + self._np_art.setAlignment(Qt.AlignmentFlag.AlignCenter) + left_layout.addWidget(self._np_art) + + info_widget = QWidget() + info_widget.setStyleSheet(_STYLE_BG_TRANSPARENT) + info_layout = QVBoxLayout(info_widget) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(0) + + info_layout.addStretch() + + self._np_title = QLabel(_STYLE_NOT_PLAYING) + self._np_title.setStyleSheet( + "color: #e8e0f0; font-size: 9.5pt; font-weight: 600;" + ) + self._np_title.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + info_layout.addWidget(self._np_title) + + self._np_artist = QLabel("") + self._np_artist.setStyleSheet( + "color: #8888aa; font-size: 8pt;" + ) + self._np_artist.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + info_layout.addWidget(self._np_artist) + + info_layout.addStretch() + + left_layout.addWidget(info_widget, 1) + + bar_layout.addWidget(left_widget, 0, Qt.AlignmentFlag.AlignLeft) + + # ═══════════════════════════════════════════════════════════════ + # CENTER — Playback controls + progress bar + # ═══════════════════════════════════════════════════════════════ + center_widget = QWidget() + center_widget.setStyleSheet(_STYLE_BG_TRANSPARENT) + center_layout = QVBoxLayout(center_widget) + center_layout.setContentsMargins(0, 8, 0, 6) + center_layout.setSpacing(4) + + # ── Button row ── + btn_row = QHBoxLayout() + btn_row.setSpacing(14) + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setAlignment(Qt.AlignmentFlag.AlignCenter) + + btn_size = 32 + icon_style = { + "normal": ( + "QPushButton { background: transparent; border: none;" + " color: #b0b0c8; font-size: 15pt; }" + ), + "hover": ( + "QPushButton:hover { color: #ffffff; }" + ), + } + btn_base = icon_style["normal"] + icon_style["hover"] + + self._loop_btn = QPushButton("↻") + self._loop_btn.setFixedSize(btn_size, btn_size) + self._loop_btn.setCheckable(True) + self._loop_btn.setToolTip("Loop") + self._loop_btn.setStyleSheet( + btn_base + + "QPushButton:checked { color: #a78bfa; }" + ) + self._loop_btn.clicked.connect(self._toggle_loop) + + self._prev_btn = QPushButton("⏮") + self._prev_btn.setFixedSize(btn_size, btn_size) + self._prev_btn.setToolTip("Restart / Previous") + self._prev_btn.setStyleSheet(btn_base) + self._prev_btn.clicked.connect(self._restart) + + self._play_btn = QPushButton("▶") + self._play_btn.setFixedSize(40, 40) + self._play_btn.setToolTip("Play / Pause") + self._play_btn.setStyleSheet( + "QPushButton { background: #ffffff; border: none;" + " border-radius: 20px; color: #0a0814;" + " font-size: 16pt; padding-left: 2px; }" + "QPushButton:hover { background: #e8e0f0; }" + "QPushButton:pressed { background: #c4b5fd; }" + ) + self._play_btn.clicked.connect(self._toggle_play) + + self._next_btn = QPushButton("⏭") + self._next_btn.setFixedSize(btn_size, btn_size) + self._next_btn.setToolTip("Next") + self._next_btn.setStyleSheet(btn_base) + self._next_btn.clicked.connect(self._next) + + self._stop_btn = QPushButton("⏹") + self._stop_btn.setFixedSize(btn_size, btn_size) + self._stop_btn.setToolTip("Stop") + self._stop_btn.setStyleSheet( + icon_style["normal"] + + "QPushButton:hover { color: #f87171; }" + ) + self._stop_btn.clicked.connect(self._stop) + + btn_row.addWidget(self._loop_btn) + btn_row.addWidget(self._prev_btn) + btn_row.addStretch() + btn_row.addWidget(self._play_btn) + btn_row.addStretch() + btn_row.addWidget(self._next_btn) + btn_row.addWidget(self._stop_btn) + center_layout.addLayout(btn_row) + + # ── Progress bar ── + time_row = QHBoxLayout() + time_row.setSpacing(8) + time_row.setContentsMargins(0, 0, 0, 0) + + self._time_lbl = QLabel("0:00") + self._time_lbl.setStyleSheet( + "color: #7777aa; font-size: 7.5pt;" + " font-family: 'Cantarell', monospace;" + ) + self._time_lbl.setFixedWidth(40) + self._time_lbl.setAlignment(Qt.AlignmentFlag.AlignRight) + + self._progress = QSlider(Qt.Orientation.Horizontal) + self._progress.setMinimum(0) + self._progress.setMaximum(1000) + self._progress.setValue(0) + self._progress.setEnabled(False) + self._progress.setFixedHeight(12) + self._progress.setStyleSheet(""" + QSlider::groove:horizontal { + height: 3px; + background: rgba(255,255,255,0.08); + border-radius: 2px; + } + QSlider::handle:horizontal { + background: #ffffff; + width: 10px; + height: 10px; + margin: -3.5px 0; + border-radius: 5px; + } + QSlider::sub-page:horizontal { + background: qlineargradient( + x1:0, y1:0, x2:1, y2:0, + stop:0 #8b5cf6, stop:1 #a78bfa + ); + border-radius: 2px; + } + QSlider::add-page:horizontal { + background: transparent; + } + """) + self._progress.valueChanged.connect(self._on_progress_change) + self._progress.sliderReleased.connect(self._on_seek_finished) + + self._dur_lbl = QLabel("0:00") + self._dur_lbl.setStyleSheet( + "color: #7777aa; font-size: 7.5pt;" + " font-family: 'Cantarell', monospace;" + ) + self._dur_lbl.setFixedWidth(40) + + time_row.addWidget(self._time_lbl) + time_row.addWidget(self._progress, 1) + time_row.addWidget(self._dur_lbl) + center_layout.addLayout(time_row) + + bar_layout.addWidget(center_widget, 1) + + # ═══════════════════════════════════════════════════════════════ + # RIGHT — Volume + # ═══════════════════════════════════════════════════════════════ + right_widget = QWidget() + right_widget.setStyleSheet(_STYLE_BG_TRANSPARENT) + right_widget.setFixedWidth(300) + right_layout = QHBoxLayout(right_widget) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(8) + right_layout.setAlignment(Qt.AlignmentFlag.AlignRight) + + self._vol_btn = QPushButton("🔊") + self._vol_btn.setFixedSize(28, 28) + self._vol_btn.setToolTip("Mute / Unmute") + self._vol_btn.setStyleSheet( + "QPushButton { border: none; color: #b0b0c8; font-size: 12pt; }" + "QPushButton:hover { color: #ffffff; }" + ) + self._vol_btn.clicked.connect(self._toggle_mute) + right_layout.addWidget(self._vol_btn) + + self._vol_slider = QSlider(Qt.Orientation.Horizontal) + self._vol_slider.setFixedWidth(140) + self._vol_slider.setMinimum(0) + self._vol_slider.setMaximum(100) + self._saved_volume = SETTINGS.get("volume", 50) + player.set_volume(self._saved_volume) + self._vol_slider.setValue(self._saved_volume) + self._vol_slider.setToolTip("Volume") + self._vol_slider.setStyleSheet(""" + QSlider::groove:horizontal { + height: 3px; + background: rgba(255,255,255,0.08); + border-radius: 2px; + } + QSlider::handle:horizontal { + background: #ffffff; + width: 10px; + height: 10px; + margin: -3.5px 0; + border-radius: 5px; + } + QSlider::sub-page:horizontal { + background: #8b5cf6; + border-radius: 2px; + } + QSlider::add-page:horizontal { + background: transparent; + } + """) + self._vol_slider.valueChanged.connect(self._on_volume_changed) + self._vol_slider.setFixedHeight(12) + + right_layout.addWidget(self._vol_slider) + bar_layout.addWidget(right_widget, 0, Qt.AlignmentFlag.AlignRight) + + main_layout.addWidget(self._glass_bar, 1) + + # ── Player signals ── + player.loading.connect(self._on_loading) + player.song_started.connect(self._on_song_started) + player.song_ended.connect(self._on_song_ended) + player.playback_state_changed.connect(self._on_state_changed) + player.position_changed.connect(self._on_position) + player.duration_changed.connect(self._on_duration) + + @Slot(str) + def _on_loading(self, video_id: str) -> None: + """Show a loading state while yt-dlp downloads the audio.""" + self._np_title.setText("Loading…") + self._np_artist.setText("") + self._play_btn.setText("⏳") + self._progress.setEnabled(False) + self._progress.setValue(0) + self._time_lbl.setText("0:00") + self._dur_lbl.setText("0:00") + self._duration_ms = 0 + self._np_art.clear() + self._np_art.setStyleSheet( + "background: rgba(0,0,0,0.3); border-radius: 4px;" + ) + self._visualizer.set_playing(False) + self._visualizer.set_loading(True) + + @Slot(dict) + def _on_song_started(self, song: dict) -> None: + self._np_title.setText(song.get("title", "Unknown")) + self._np_artist.setText(_artists_str(song.get("artists"))) + self._play_btn.setText("⏸") + self._progress.setEnabled(True) + self._visualizer.set_loading(False) + self._visualizer.set_playing(True) + self._load_art(self._np_art, song) + + @Slot(str) + def _on_song_ended(self, video_id: str) -> None: + if self._player.get_queue_length() > 0: + return + self._np_title.setText(_STYLE_NOT_PLAYING) + self._np_artist.setText("") + self._np_art.clear() + self._play_btn.setText("▶") + self._progress.setValue(0) + self._time_lbl.setText("0:00") + self._dur_lbl.setText("0:00") + self._duration_ms = 0 + self._progress.setEnabled(False) + self._visualizer.set_playing(False) + self._visualizer.set_loading(False) + + @Slot(str) + def _on_state_changed(self, state: str) -> None: + if state == "playing": + self._play_btn.setText("⏸") + self._visualizer.set_playing(True) + else: + self._play_btn.setText("▶") + self._visualizer.set_playing(False) + + @Slot(int) + def _on_progress_change(self, value: int) -> None: + if self._progress.isSliderDown(): + self._seek(value) + + def _on_seek_finished(self) -> None: + """Perform the final seek when user releases the slider. + + The last ``valueChanged`` during drag may not reach ``_seek`` + because ``isSliderDown()`` is already ``False`` by the time it + fires. This ensures the final position is always applied and + the ``seeked`` signal (→ Discord RPC, → AGC reset) is emitted. + """ + if self._duration_ms > 0: + self._seek(self._progress.value()) + + @Slot(int) + def _on_position(self, ms: int) -> None: + if self._updating_progress: + return + if self._duration_ms > 0: + self._progress.blockSignals(True) + self._progress.setValue(int(ms * 1000 / self._duration_ms)) + self._progress.blockSignals(False) + self._time_lbl.setText(_fmt_ms(ms)) + + @Slot(int) + def _on_duration(self, ms: int) -> None: + self._duration_ms = ms + self._dur_lbl.setText(_fmt_ms(ms)) + + def _toggle_play(self) -> None: + self._player.toggle_pause() + + def _toggle_loop(self) -> None: + enabled = not self._player.get_loop() + self._player.set_loop(enabled) + self._loop_btn.setChecked(enabled) + + def _restart(self) -> None: + self._player.seek(0) + + def _next(self) -> None: + self._player.skip() + + def _stop(self) -> None: + self._player.stop_playback() + self._np_title.setText(_STYLE_NOT_PLAYING) + self._np_artist.setText("") + self._np_art.clear() + self._play_btn.setText("▶") + self._progress.setValue(0) + self._time_lbl.setText("0:00") + self._dur_lbl.setText("0:00") + self._duration_ms = 0 + self._progress.setEnabled(False) + self._visualizer.set_playing(False) + self._visualizer.set_loading(False) + + def _seek(self, value: int) -> None: + if self._duration_ms > 0: + self._updating_progress = True + self._progress.blockSignals(True) + ms = int(value * self._duration_ms / 1000) + self._player.seek(ms) + self._progress.blockSignals(False) + # Reset per-band AGC peaks so the visualizer adapts quickly + # to the new audio section instead of decaying through idle. + if hasattr(self, "_band_peaks"): + self._band_peaks = [0.001] * len(self._band_peaks) + self._visualizer._last_activity = 0 + QTimer.singleShot(200, lambda: setattr(self, "_updating_progress", False)) + + def _toggle_mute(self) -> None: + if self._player.volume() > 0: + self._prev_vol = self._player.volume() + self._player.set_volume(0) + self._vol_slider.blockSignals(True) + self._vol_slider.setValue(0) + self._vol_slider.blockSignals(False) + self._vol_btn.setText("🔇") + else: + restore = getattr(self, "_prev_vol", self._saved_volume) + self._player.set_volume(restore) + self._vol_slider.blockSignals(True) + self._vol_slider.setValue(restore) + self._vol_slider.blockSignals(False) + self._saved_volume = restore + if restore == 0: + self._vol_btn.setText("🔇") + elif restore < 50: + self._vol_btn.setText("🔉") + else: + self._vol_btn.setText("🔊") + + @Slot(int) + def _on_volume_changed(self, value: int) -> None: + self._player.set_volume(value) + self._saved_volume = value + if value == 0: + self._vol_btn.setText("🔇") + elif value < 50: + self._vol_btn.setText("🔉") + else: + self._vol_btn.setText("🔊") + if not hasattr(self, "_vol_save_timer"): + self._vol_save_timer = QTimer(self) + self._vol_save_timer.setSingleShot(True) + self._vol_save_timer.setInterval(500) + self._vol_save_timer.timeout.connect( + lambda: save_volume(self._saved_volume) + ) + self._vol_save_timer.start() + + # ── Visualizer data feeding ── + # FFT frame size — larger = finer low-frequency resolution. + # At 48 kHz: 4096 samples → 11.7 Hz/bin with Hann window for clean bars. + _FFT_SIZE = 4096 + + # Per-band peak tracking for AGC — each band normalises by its own + # decaying maximum so heavy bass doesn't drown out mids/treble. + _BAND_PEAK_ATTACK = 0.35 # how fast the peak tracks up (per frame) + _BAND_PEAK_DECAY = 0.97 # how fast the peak decays (per frame) + + # Number of log-spaced frequency bands for half the spectrum. + # Fewer-than-bar bands are interpolated across the visualizer width + # so each bar carries a unique interpolated value — no clumping + # at the mirrored center. 80 bands from 20 Hz → 20 kHz gives each + # band ≈ 9.5 % of a decade (vs 3.4 % at 128), keeping them distinct. + _NUM_BARS_HALF = 80 + + def _on_audio_buffer(self, buffer) -> None: + """Called when a new audio buffer arrives from QAudioBufferOutput. + + Decodes raw audio, applies a Hann window, runs the radix-2 FFT, + and maps bins to per-bar log-spaced magnitudes for the visualizer. + + Per-band AGC: each frequency band normalises by its own decaying + peak, so heavy bass doesn't wash out mids and treble. + + Silently drops data when the parent window is minimised — the FFT + is the most CPU-intensive operation in the UI and is pointless when + nobody is looking. + """ + if not self._visualizer.is_active(): + return + + w = self.window() + if w is not None and (w.isMinimized() or w.isHidden()): + return + + data = buffer.constData() + if not data or len(data) == 0: + return + + sample_rate = buffer.format().sampleRate() + bytes_per_sample = buffer.format().bytesPerSample() + num_samples = len(data) // bytes_per_sample + bytes_per_frame = bytes_per_sample * buffer.format().channelCount() + channel_count = buffer.format().channelCount() + + if num_samples < self._FFT_SIZE: + return + + # ── Decode first _FFT_SIZE samples → mono float list ── + samples = [0.0] * self._FFT_SIZE + fmt = buffer.format() + for i in range(self._FFT_SIZE): + offset = i * bytes_per_frame + acc = 0.0 + for ch in range(channel_count): + ch_offset = offset + ch * bytes_per_sample + acc += fmt.normalizedSampleValue( + data[ch_offset : ch_offset + bytes_per_sample] + ) + samples[i] = acc / channel_count + + # ── Per-bar spectrum → per-band AGC → visualizer ── + bar_mags = self._compute_bar_spectrum(samples, sample_rate) + normalised = self._normalise_per_band(bar_mags) + self._visualizer.set_frequency_data(normalised) + + # ── Per-band AGC ──────────────────────────────────────────────────── + + def _normalise_per_band(self, bar_mags: list[float]) -> list[float]: + """Normalise each band by its own decaying peak (per-band AGC). + + Each band tracks a running maximum with fast attack and slow decay. + This means every frequency range gets to fill the visualizer + independently — bass doesn't steal dynamic range from mids/treble. + """ + if not hasattr(self, "_band_peaks"): + self._band_peaks = [0.001] * len(bar_mags) + + peaks = self._band_peaks + attack = self._BAND_PEAK_ATTACK + decay = self._BAND_PEAK_DECAY + + result = [0.0] * len(bar_mags) + for i, mag in enumerate(bar_mags): + # Update tracked peak: fast upward, slow downward + if mag > peaks[i]: + peaks[i] += (mag - peaks[i]) * attack + else: + peaks[i] *= decay + # Clamp so silence doesn't amplify noise + if peaks[i] < 0.001: + peaks[i] = 0.001 + + result[i] = mag / peaks[i] + # Soft-clip to [0, 1] so outliers don't distort the display + if result[i] > 1.0: + result[i] = 1.0 + + return result + + def _compute_bar_spectrum(self, samples, sample_rate): + """Per-band log-spaced magnitudes via Hann-windowed radix-2 FFT. + + Returns ``_NUM_BARS_HALF`` values (left half of the spectrum). + The AudioVisualizer interpolates and mirrors these across all bars. + """ + num_bars_half = self._NUM_BARS_HALF + n = len(samples) + + # ── Hann window — reduces spectral leakage for cleaner bars ── + windowed = [0.0] * n + denom = float(n - 1) if n > 1 else 1.0 + for i in range(n): + w = 0.5 * (1.0 - math.cos(2.0 * math.pi * i / denom)) + windowed[i] = samples[i] * w + + mags = _fft_magnitudes(windowed) + half_n = len(mags) - 1 # index of Nyquist bin + nyquist = sample_rate / 2.0 + freq_per_bin = nyquist / half_n # Hz per FFT bin + + # ── Logarithmic frequency scale (perceptual, like human hearing) ── + min_freq = 20.0 + max_freq = min(nyquist, 20000.0) + log_min = math.log10(min_freq) + log_max = math.log10(max_freq) + span = log_max - log_min + + result = [0.0] * num_bars_half + for i in range(num_bars_half): + t = i / max(num_bars_half - 1, 1) + log_center = log_min + t * span + + # Band edges at midpoints between adjacent bar centers + if i > 0: + prev_log = log_min + (i - 1) / max(num_bars_half - 1, 1) * span + else: + prev_log = log_min - span / num_bars_half + if i < num_bars_half - 1: + next_log = log_min + (i + 1) / max(num_bars_half - 1, 1) * span + else: + next_log = log_max + span / num_bars_half + + low_freq = 10.0 ** ((log_center + prev_log) / 2.0) + high_freq = 10.0 ** ((log_center + next_log) / 2.0) + + low_bin = max(1, int(low_freq / freq_per_bin)) + high_bin = min(half_n, int(high_freq / freq_per_bin)) + + if low_bin > high_bin: + continue + + energy = 0.0 + for k in range(low_bin, high_bin + 1): + energy += mags[k] * mags[k] + result[i] = math.sqrt(energy / (high_bin - low_bin + 1)) + + return result + + def _flush_volume(self) -> None: + """Immediately persist the current volume, bypassing the debounce timer.""" + if hasattr(self, "_vol_save_timer") and self._vol_save_timer.isActive(): + self._vol_save_timer.stop() + save_volume(self._saved_volume) + + def set_volume(self, volume: int) -> None: + """Delegate volume to player.""" + self._player.set_volume(volume) + + def _load_art(self, art_label: QLabel, song: dict): + """Load thumbnail from cache or network.""" + thumb_url = _best_thumbnail(song.get("thumbnails") or song.get("thumbnail")) + if not thumb_url: + return + video_id = song.get("videoId") or song.get("video_id", "") + if not video_id: + return + _load_thumbnail( + video_id, thumb_url, art_label, + size=56, + style_pass=_STYLE_BG_TRANSPARENT, + style_fail="background: rgba(0,0,0,0.25); border-radius: 4px;", + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Search page +# ═══════════════════════════════════════════════════════════════════════════════ + + +class SearchPage(QWidget): + """Search page with categorised results (Videos / Songs). + + Each category shows the first ``_MAX_VISIBLE`` results with a + "Show more" link to expand. Sections that have zero results + are omitted entirely. + """ + + _MAX_VISIBLE = 3 + + def __init__(self, parent=None): + super().__init__(parent) + self._all_videos: list[dict] = [] + self._all_songs: list[dict] = [] + self._videos_expanded = False + self._songs_expanded = False + self._play_callback = None + self._fav_callback = None + self._queue_next_callback = None + + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(16) + + header = QLabel("🔍 Search") + hf = QFont(FONT) + hf.setPointSize(18) + hf.setBold(True) + header.setFont(hf) + header.setStyleSheet(_STYLE_HEADER_WHITE) + layout.addWidget(header) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setStyleSheet(""" + QScrollArea { + background: transparent; + border: none; + } + """) + + self._results = QWidget() + self._results.setStyleSheet(_STYLE_BG_TRANSPARENT) + self._results_layout = QVBoxLayout(self._results) + self._results_layout.setContentsMargins(0, 0, 0, 0) + self._results_layout.setSpacing(4) + + scroll.setWidget(self._results) + layout.addWidget(scroll, 1) + + # ── Public API ──────────────────────────────────────────────────────── + + def clear(self) -> None: + """Clear all results and reset expand state.""" + self._all_videos = [] + self._all_songs = [] + self._videos_expanded = False + self._songs_expanded = False + self._play_callback = None + self._fav_callback = None + self._queue_next_callback = None + self._rebuild() + + def show_loading(self) -> None: + """Show a "Searching…" placeholder while the thread runs.""" + self._all_videos = [] + self._all_songs = [] + self._videos_expanded = False + self._songs_expanded = False + self._play_callback = None + self._fav_callback = None + self._queue_next_callback = None + + while self._results_layout.count(): + item = self._results_layout.takeAt(0) + if item is not None: + w = item.widget() + if w: + w.deleteLater() + + lbl = QLabel("Searching…") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setStyleSheet("color: #8888aa; font-size: 11pt; padding: 40px;") + self._results_layout.addWidget(lbl) + self._results_layout.addStretch() + + def display_results(self, videos: list[dict], songs: list[dict], + play_callback, fav_callback, + queue_next_callback=None) -> None: + """Populate the page with categorized search results. + + *videos* and *songs* are lists of song dicts (each should + contain at least ``videoId``, ``title``, ``artists``, and + optionally ``_is_fav``). + """ + self._all_videos = videos + self._all_songs = songs + self._videos_expanded = False + self._songs_expanded = False + self._play_callback = play_callback + self._fav_callback = fav_callback + self._queue_next_callback = queue_next_callback + self._rebuild() + + def refresh_fav_states(self, db) -> None: + """Refresh star display from the database. + + Called when a favourite is toggled on another page so the + search results stay in sync. + """ + for i in range(self._results_layout.count()): + item = self._results_layout.itemAt(i) + if item is None: + continue + w = item.widget() + if isinstance(w, SongItemWidget): + vid = w._video_id + is_fav = db.is_favourite(vid) if vid else False + w._is_fav = is_fav + w._fav_btn.setText("★" if is_fav else "☆") + + # ── Internal helpers ────────────────────────────────────────────────── + + def _render_category(self, songs: list, header_text: str, expanded: bool, category: str) -> None: + if not songs: + return + self._add_header(header_text) + shown = songs if expanded else songs[:self._MAX_VISIBLE] + for song in shown: + self._add_song_item(song) + if len(songs) > self._MAX_VISIBLE and not expanded: + self._add_show_more(category) + + def _rebuild(self) -> None: + # Clear layout + while self._results_layout.count(): + item = self._results_layout.takeAt(0) + if item is not None: + w = item.widget() + if w: + w.deleteLater() + + has_any = False + + if self._all_videos: + has_any = True + self._render_category(self._all_videos, "🎬 Videos", self._videos_expanded, "videos") + if self._all_songs: + has_any = True + self._render_category(self._all_songs, "🎵 Songs", self._songs_expanded, "songs") + + # ── Empty state ─────────────────────────────────────────────────── + if not has_any: + lbl = QLabel("No results found.") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setStyleSheet("color: #8888aa; font-size: 11pt; padding: 40px;") + self._results_layout.addWidget(lbl) + + # Push remaining space to the bottom so headers stay top-aligned + self._results_layout.addStretch() + + def _add_header(self, text: str) -> None: + h = QLabel(text) + hf = QFont(FONT) + hf.setPointSize(11) + hf.setBold(True) + h.setFont(hf) + h.setStyleSheet("color: #a0a0cc; padding: 10px 4px 2px 4px;") + self._results_layout.addWidget(h) + + def _add_song_item(self, song: dict) -> None: + is_fav = song.get("_is_fav", False) + widget = SongItemWidget(song, is_fav=is_fav) + if self._play_callback: + widget.play_requested.connect(self._play_callback) + if self._fav_callback: + widget.fav_toggled.connect(self._fav_callback) + if self._queue_next_callback: + widget.queue_next_requested.connect(self._queue_next_callback) + self._results_layout.addWidget(widget) + + def _add_show_more(self, category: str) -> None: + btn = QPushButton("⋯ Show more") + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setFixedHeight(38) + btn.setStyleSheet(""" + QPushButton { + border: none; + border-radius: 8px; + background: transparent; + color: #818cf8; + font-size: 9pt; + padding: 8px; + text-align: center; + } + QPushButton:hover { + background: rgba(129, 140, 248, 0.10); + color: #a5b4fc; + } + """) + btn.clicked.connect(lambda: self._toggle_expand(category)) + self._results_layout.addWidget(btn) + + def _toggle_expand(self, category: str) -> None: + if category == "videos": + self._videos_expanded = True + else: + self._songs_expanded = True + self._rebuild() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Stats page +# ═══════════════════════════════════════════════════════════════════════════════ + + +class StatsPage(QWidget): + """Analytics dashboard with glass cards.""" + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(16) + + header = QLabel("📊 Listening Stats") + hf = QFont(FONT) + hf.setPointSize(18) + hf.setBold(True) + header.setFont(hf) + header.setStyleSheet("color: #ffffff; padding-bottom: 8px;") + layout.addWidget(header) + + # Summary cards in a grid + cards_layout = QHBoxLayout() + cards_layout.setSpacing(12) + + self._card_unique, self._val_unique = self._make_stat_card("🎵", "Unique Songs", "0") + self._card_total, self._val_total = self._make_stat_card("▶", "Total Plays", "0") + self._card_fav, self._val_fav = self._make_stat_card("⭐", "Favourites", "0") + + cards_layout.addWidget(self._card_unique) + cards_layout.addWidget(self._card_total) + cards_layout.addWidget(self._card_fav) + cards_layout.addStretch() + layout.addLayout(cards_layout) + + # Top played + self._top_card = GlassCard(self, glow_color=(20, 15, 40)) + self._top_card.set_transparency(20) + self._top_card.setMaximumHeight(400) + top_layout = QVBoxLayout(self._top_card) + top_layout.setContentsMargins(20, 16, 20, 16) + + top_hdr = QLabel("Top Played") + top_hdr.setStyleSheet("color: #ffffff; font-size: 12pt; font-weight: bold; padding-bottom: 10px;") + top_layout.addWidget(top_hdr) + + self._top_list = QVBoxLayout() + self._top_list.setContentsMargins(0, 0, 0, 0) + top_layout.addLayout(self._top_list) + top_layout.addStretch() + layout.addWidget(self._top_card) + + # Plays by day + self._day_card = GlassCard(self, glow_color=(20, 15, 40)) + self._day_card.set_transparency(20) + self._day_card.setMaximumHeight(300) + day_layout = QVBoxLayout(self._day_card) + day_layout.setContentsMargins(20, 16, 20, 16) + + day_hdr = QLabel("Plays by Day") + day_hdr.setStyleSheet("color: #ffffff; font-size: 12pt; font-weight: bold; padding-bottom: 10px;") + day_layout.addWidget(day_hdr) + + self._day_list = QVBoxLayout() + self._day_list.setContentsMargins(0, 0, 0, 0) + day_layout.addLayout(self._day_list) + day_layout.addStretch() + layout.addWidget(self._day_card) + + layout.addStretch() + + @staticmethod + def _make_top_row(rank: int, title: str, artists: str, + play_count: int, max_count: int, + video_id: str = "", thumb_url: str = "") -> QWidget: + """Build a visually rich row for the Top Played list.""" + # Medal colours for top 3 + rank_colors = {1: ("#ffd700", "#55401a"), # gold + 2: ("#c0c0c0", "#3a3a42"), # silver + 3: ("#cd7f32", "#3e2a1a")} # bronze + text_color, bg_color = rank_colors.get(rank, ("#8888aa", "transparent")) + + row = QWidget() + row.setFixedHeight(42) + h = QHBoxLayout(row) + h.setContentsMargins(4, 0, 4, 0) + h.setSpacing(8) + + # Rank badge + badge = QLabel(str(rank)) + badge.setFixedSize(24, 24) + badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + badge.setStyleSheet(_css( + f"background: {bg_color};" if bg_color != "transparent" else "", + f"color: {text_color};", + "border-radius: 12px; font-size: 9pt; font-weight: bold;", + )) + h.addWidget(badge) + + # Album art thumbnail (32×32, rounded) + art = QLabel("") + art.setFixedSize(32, 32) + art.setAlignment(Qt.AlignmentFlag.AlignCenter) + art.setStyleSheet("background-color: #1a1530; border-radius: 6px; color: #555; font-size: 14px;") + art.setText("♪") + h.addWidget(art) + + # Load thumbnail from cache or network + if video_id and thumb_url: + _load_thumbnail( + video_id, thumb_url, art, + size=32, + style_pass="border-radius: 6px; background: transparent;", + style_fail="background-color: #1a1530; border-radius: 6px;", + ) + + # Title + artists + text_w = QWidget() + text_w.setStyleSheet(_STYLE_BG_TRANSPARENT) + tl = QVBoxLayout(text_w) + tl.setContentsMargins(0, 0, 0, 0) + tl.setSpacing(0) + + t = QLabel(title) + t.setStyleSheet("color: #e0d8f0; font-size: 10pt; font-weight: bold; background: transparent;") + t.setWordWrap(False) + tl.addWidget(t) + + if artists: + a = QLabel(artists) + a.setStyleSheet("color: #8888aa; font-size: 8pt; background: transparent;") + a.setWordWrap(False) + tl.addWidget(a) + + h.addWidget(text_w, 1) + + # Frequency bar + count + frac = play_count / max_count if max_count > 0 else 0 + bar_w = int(80 * frac) + + count_row = QHBoxLayout() + count_row.setSpacing(6) + count_row.setContentsMargins(0, 0, 0, 0) + + bar_container = QWidget() + bar_container.setFixedSize(80, 6) + bar_container.setStyleSheet("background: rgba(255,255,255,0.06); border-radius: 3px;") + + fill = QWidget(bar_container) + fill.setFixedSize(bar_w, 6) + bar_color = "#8b5cf6" if rank > 3 else text_color + fill.setStyleSheet(f"background: {bar_color}; border-radius: 3px;") + + count_row.addWidget(bar_container) + + cnt = QLabel(f"{play_count}") + cnt.setFixedWidth(28) + cnt.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + cnt.setStyleSheet("color: #aaaacc; font-size: 9pt; font-family: monospace;") + cnt.setFixedHeight(20) + count_row.addWidget(cnt) + + count_w = QWidget() + count_w.setLayout(count_row) + count_w.setStyleSheet(_STYLE_BG_TRANSPARENT) + h.addWidget(count_w) + + return row + + @staticmethod + def _make_stat_card(icon: str, label: str, value: str) -> tuple: + card = GlassCard(glow_color=(20, 15, 40)) + card.set_transparency(25) + card.setMinimumWidth(140) + card.setMaximumHeight(100) + cl = QVBoxLayout(card) + cl.setContentsMargins(16, 12, 16, 12) + cl.setAlignment(Qt.AlignmentFlag.AlignCenter) + + icon_lbl = QLabel(icon) + icon_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + icon_lbl.setStyleSheet("font-size: 20px;") + cl.addWidget(icon_lbl) + + val = QLabel(value) + val.setObjectName("stat_value") + val.setAlignment(Qt.AlignmentFlag.AlignCenter) + val.setStyleSheet("color: #ffffff; font-size: 18pt; font-weight: bold;") + cl.addWidget(val) + + lbl = QLabel(label) + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setStyleSheet("color: #8888aa; font-size: 9pt;") + cl.addWidget(lbl) + + return card, val + + @staticmethod + def _clear_list_widget(layout) -> None: + for i in range(layout.count()): + item = layout.itemAt(i) + if item is not None: + w = item.widget() + if w: + w.deleteLater() + + def _render_top_played(self, stats: dict) -> None: + top_data = stats.get("top_played", [])[:8] + max_count = max((s["play_count"] for s in top_data), default=1) + for rank, s in enumerate(top_data, start=1): + artists = _artists_from_json(s["artists"]) + thumb = s.get("thumbnail", "") or "" + row = self._make_top_row( + rank, s["title"], artists, s["play_count"], max_count, + video_id=s.get("video_id", ""), + thumb_url=_best_thumbnail(thumb), + ) + self._top_list.addWidget(row) + + def _render_plays_by_day(self, stats: dict) -> None: + for d in stats.get("plays_by_day", [])[:12]: + bar = "█" * min(d["plays"], 40) + row = QLabel(f" {d['day']} {bar} {d['plays']}") + row.setStyleSheet("color: #aaaacc; padding: 1px 0; font-size: 9pt; font-family: monospace;") + self._day_list.addWidget(row) + + def set_stats(self, stats: dict) -> None: + if self._val_unique: + self._val_unique.setText(str(stats["unique_songs"])) + if self._val_total: + self._val_total.setText(str(stats["total_plays"])) + if self._val_fav: + self._val_fav.setText(str(stats["favourites"])) + + self._clear_list_widget(self._top_list) + self._render_top_played(stats) + + self._clear_list_widget(self._day_list) + self._render_plays_by_day(stats) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Settings dialog +# ═══════════════════════════════════════════════════════════════════════════════ + + +class SettingsDialog(QDialog): + """Settings window with grouped toggles.""" + + def __init__(self, main_window, parent=None): + super().__init__(parent) + self._main_window = main_window + self.setWindowTitle("Settings") + self.setFixedSize(420, 320) + self.setStyleSheet(""" + QDialog { + background: #0f0c1e; + border: 1px solid rgba(139, 92, 246, 0.20); + border-radius: 12px; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 20, 24, 20) + layout.setSpacing(8) + + header = QLabel("⚙ Settings") + hf = QFont(FONT) + hf.setPointSize(16) + hf.setBold(True) + header.setFont(hf) + header.setStyleSheet(_STYLE_HEADER_WHITE) + layout.addWidget(header) + + # ── Misc section ──────────────────────────────────────────────── + layout.addWidget(self._section_header("Misc")) + + self._visualizer_cb = QCheckBox("Visualizer") + self._visualizer_cb.setChecked(get_setting("visualizer_enabled")) + self._visualizer_cb.toggled.connect(self._on_visualizer_toggled) + layout.addWidget(self._check_style(self._visualizer_cb)) + + self._discord_cb = QCheckBox("Discord RPC") + self._discord_cb.setChecked(get_setting("discord_rpc_enabled")) + self._discord_cb.toggled.connect(self._on_discord_toggled) + layout.addWidget(self._check_style(self._discord_cb)) + + layout.addStretch() + + # ── Close button ── + btn_row = QHBoxLayout() + btn_row.addStretch() + close_btn = QPushButton("Close") + close_btn.setFixedSize(100, 34) + close_btn.setStyleSheet(""" + QPushButton { + background: rgba(139, 92, 246, 0.20); + border: 1px solid rgba(139, 92, 246, 0.30); + border-radius: 8px; + color: #e0d8f0; + font-size: 9pt; + } + QPushButton:hover { + background: rgba(139, 92, 246, 0.35); + color: #ffffff; + } + """) + close_btn.clicked.connect(self.accept) + btn_row.addWidget(close_btn) + layout.addLayout(btn_row) + + # ── Internal helpers ──────────────────────────────────────────────── + + @staticmethod + def _section_header(text: str) -> QLabel: + lbl = QLabel(text) + hf = QFont(FONT) + hf.setPointSize(10) + hf.setBold(True) + lbl.setFont(hf) + lbl.setStyleSheet("color: #a0a0cc; padding: 8px 0 2px 0;") + return lbl + + @staticmethod + def _check_style(cb: QCheckBox) -> QCheckBox: + cb.setStyleSheet(""" + QCheckBox { + spacing: 10px; + color: #e0d8f0; + font-size: 10pt; + padding: 4px 0; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 4px; + border: 1px solid rgba(139, 92, 246, 0.30); + background: rgba(255,255,255,0.04); + } + QCheckBox::indicator:checked { + background: rgba(139, 92, 246, 0.60); + border: 1px solid rgba(139, 92, 246, 0.60); + } + QCheckBox::indicator:hover { + border: 1px solid rgba(139, 92, 246, 0.60); + } + """) + return cb + + # ── Slots ──────────────────────────────────────────────────────────── + + def _on_visualizer_toggled(self, enabled: bool) -> None: + save_setting("visualizer_enabled", enabled) + viz = self._main_window._visualizer + if enabled: + viz.setVisible(True) + viz.setFixedHeight(44) + viz.setMaximumHeight(44) + else: + viz.setVisible(False) + viz.setFixedHeight(0) + viz.setMaximumHeight(0) + + def _on_discord_toggled(self, enabled: bool) -> None: + save_setting("discord_rpc_enabled", enabled) + if enabled: + self._main_window.rpc.start() + # Re-send current song if one is playing. + current = self._main_window.player.get_current() + if current: + self._main_window.rpc.update_song(current) + else: + self._main_window.rpc.stop() + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _artists_from_json(j: str) -> str: + try: + arr = json.loads(j) + return ", ".join(a.get("name", "") for a in arr if isinstance(a, dict)) + except (json.JSONDecodeError, TypeError): + return j or "" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Main window +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TunettiWindow(QMainWindow): + """Aurora main window – immersive, glassmorphic, animated.""" + + NAV_ITEMS = [ + ("🔍", "Search"), + ("⭐", "Favourites"), + ("📋", "History"), + ("📊", "Stats"), + ] + + def __init__(self): + super().__init__() + self.setWindowTitle("Tunetti") + self.setMinimumSize(960, 600) + self.resize(1300, 820) + + # Set application-wide style + QApplication.setStyle("Fusion") + + # ── Core services ── + self.db = MusicDB(SETTINGS["db_path"]) + self.rpc = DiscordRPC(SETTINGS["discord_client_id"]) + self.player = AudioPlayer(self) + + # ── Central widget ── + central = QWidget() + central.setStyleSheet("background-color: #0a0814;") + self.setCentralWidget(central) + self._main_layout = QVBoxLayout(central) + self._main_layout.setContentsMargins(0, 0, 0, 0) + self._main_layout.setSpacing(0) + + # ── Search bar (top) ── + self._main_layout.addWidget(self._build_search_bar()) + + # ── Content area ── + content = QHBoxLayout() + content.setContentsMargins(0, 0, 0, 86) # bottom margin = glass card height + content.setSpacing(0) + + # Sidebar + self._sidebar = self._build_sidebar() + content.addWidget(self._sidebar, 0) + + # Page stack + self._stack = QStackedWidget() + self._stack.setStyleSheet(_STYLE_BG_TRANSPARENT) + content.addWidget(self._stack, 1) + + self._main_layout.addLayout(content, 1) + + # ── Pages ── + self._search_page = SearchPage() + self._fav_page = self._make_list_page("⭐ Favourites") + self._hist_page = self._make_list_page("📋 History") + self._stats_page = StatsPage() + + self._stack.addWidget(self._search_page) + self._stack.addWidget(self._fav_page) + self._stack.addWidget(self._hist_page) + self._stack.addWidget(self._stats_page) + + # ── Visualizer (separate widget, float-over, transparent to clicks) ── + self._visualizer = AudioVisualizer(self) + self._visualizer.setMinimumHeight(0) + self._visualizer.setMaximumHeight(44) + self._visualizer.setFixedHeight(44) + self._visualizer.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self._visualizer.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self._visualizer.setStyleSheet(_STYLE_BG_TRANSPARENT) + + # ── Playback bar (glass controls — on top of content) ── + self._playback = PlaybackBar(self.player, self._visualizer, self) + + # ── Player signal connections ── + self.player.song_started.connect(self._on_song_started) + self.player.song_ended.connect(self._on_song_ended) + self.player.playback_state_changed.connect(self._on_playback_state_changed) + self.player.error.connect(self._on_player_error) + self.player.seeked.connect(self._on_player_seeked) + + # ── Visualizer audio buffer input ── + # Connect the player's audio buffer output to our visualizer + self._audio_buffer_output = QAudioBufferOutput(self) + self.player._player.setAudioBufferOutput(self._audio_buffer_output) + self._audio_buffer_output.audioBufferReceived.connect( + self._playback._on_audio_buffer + ) + + # ── Search debounce ── + # Track current search thread for cleanup + self._search_thread = None + + self._search_timer = QTimer(self) + self._search_timer.setSingleShot(True) + self._search_timer.setInterval(350) + self._search_timer.timeout.connect(self._do_search) + + # ── Minimized-state detection ── + # Primary: changeEvent(QEvent.WindowStateChange) — synchronous, + # instant detection on most platforms. + # Fallback: 500 ms poll for platforms where WindowStateChange + # events are unreliable (certain Linux window managers / Wayland). + self._minimized = False + self._min_timer = QTimer(self) + self._min_timer.setInterval(500) + self._min_timer.timeout.connect(self._poll_minimized) + self._min_timer.start() + + # ── Apply saved settings ── + if not get_setting("visualizer_enabled"): + self._visualizer.setVisible(False) + self._visualizer.setFixedHeight(0) + self._visualizer.setMaximumHeight(0) + + if get_setting("discord_rpc_enabled"): + self.rpc.start() + + # ── Default nav ── + self._nav_group.button(0).setChecked(True) + self._switch_page(0) + + # ── UI Builders ── + + def _build_search_bar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(52) + bar.setStyleSheet(""" + QWidget { + background: rgba(15, 12, 30, 0.7); + border-bottom: 1px solid rgba(139, 92, 246, 0.15); + } + """) + layout = QHBoxLayout(bar) + layout.setContentsMargins(20, 6, 20, 6) + + title = QLabel("♫ Tunetti") + tf = QFont(FONT) + tf.setPointSize(15) + tf.setBold(True) + title.setFont(tf) + title.setStyleSheet("color: #c4b5fd;") + layout.addWidget(title) + + self._search_input = QLineEdit() + self._search_input.setPlaceholderText("Search songs, artists, albums…") + self._search_input.setFixedHeight(34) + self._search_input.setClearButtonEnabled(True) + self._search_input.textChanged.connect(self._on_search_text_changed) + self._search_input.returnPressed.connect(self._do_search) + self._search_input.setStyleSheet(""" + QLineEdit { + background: rgba(255,255,255,0.06); + border: 1px solid rgba(139, 92, 246, 0.20); + border-radius: 17px; + padding: 6px 16px; + color: #e8e0f0; + font-size: 10pt; + } + QLineEdit:focus { + border: 1px solid rgba(139, 92, 246, 0.50); + background: rgba(255,255,255,0.08); + } + """) + layout.addWidget(self._search_input, 1) + return bar + + def _build_sidebar(self) -> QFrame: + frame = QFrame() + frame.setFixedWidth(200) + frame.setStyleSheet(""" + QFrame { + background: rgba(12, 10, 25, 0.6); + border-right: 1px solid rgba(139, 92, 246, 0.10); + } + """) + layout = QVBoxLayout(frame) + layout.setContentsMargins(12, 12, 8, 12) + layout.setSpacing(2) + + # Sidebar toggle button (collapse/expand) — styled like NavButton + self._toggle_btn = QPushButton("☰") + self._toggle_btn.setFixedHeight(44) + self._toggle_btn.setStyleSheet(""" + QPushButton { + border: none; + border-radius: 10px; + color: #aaaacc; + font-size: 10pt; + background: transparent; + padding: 0 14px; + text-align: left; + } + QPushButton:hover { + background: rgba(139, 92, 246, 0.12); + color: #ffffff; + } + """) + # Sidebar animation + self._sidebar_anim = QPropertyAnimation(frame, b"minimumWidth") + self._sidebar_anim.setDuration(200) + self._sidebar_anim.setEasingCurve(QEasingCurve.Type.OutCubic) + + self._toggle_btn.setToolTip("Collapse sidebar") + self._toggle_btn.clicked.connect(self._toggle_sidebar) + self._sidebar_collapsed = False + self._sidebar_expanded_width = 200 + self._sidebar_collapsed_width = 52 + layout.addWidget(self._toggle_btn, alignment=Qt.AlignmentFlag.AlignRight) + + self._nav_group = QButtonGroup(self) + self._nav_group.setExclusive(True) + self._nav_group.idClicked.connect(self._switch_page) + + for idx, (icon, label) in enumerate(self.NAV_ITEMS): + btn = NavButton(icon, label, idx) + btn.setObjectName(f"nav-{idx}") + self._nav_group.addButton(btn, idx) + layout.addWidget(btn, 0) + + layout.addStretch() + + # ── Settings button (always visible) ── + self._settings_btn = QPushButton("⚙ Settings") + self._settings_btn.setFixedHeight(44) + self._settings_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._settings_btn.setStyleSheet(""" + QPushButton { + border: none; + border-radius: 10px; + color: #8888aa; + font-size: 10pt; + background: transparent; + padding: 0 14px; + text-align: left; + } + QPushButton:hover { + background: rgba(139, 92, 246, 0.12); + color: #ffffff; + } + """) + self._settings_btn.clicked.connect(self._open_settings) + layout.addWidget(self._settings_btn) + return frame + + def _make_list_page(self, header_text: str) -> QWidget: + """Create a page with a header label and a list widget. + + The returned widget stores the list as ``._list`` for callers + that need to populate it. + """ + container = QWidget() + # Expose the inner list widget for callers via _list attribute + list_widget = QListWidget() + container._list = list_widget + list_widget.setObjectName("list") + list_widget.setStyleSheet(""" + QListWidget { + background: transparent; + border: none; + outline: none; + border-radius: 12px; + } + QListWidget::item { + border-radius: 10px; + margin: 4px 0; + padding: 0; + } + QListWidget::item:selected { + background: rgba(139, 92, 246, 0.15); + } + QListWidget::item:hover { + background: rgba(255,255,255,0.04); + } + """) + list_widget.setAlternatingRowColors(False) + list_widget.setSpacing(6) + list_widget.setResizeMode(QListWidget.ResizeMode.Adjust) + list_widget.setWordWrap(False) + list_widget.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) + + layout = QVBoxLayout(container) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(16) + + header = QLabel(header_text) + hf = QFont(FONT) + hf.setPointSize(18) + hf.setBold(True) + header.setFont(hf) + header.setStyleSheet(_STYLE_HEADER_WHITE) + layout.addWidget(header) + layout.addWidget(list_widget, 1) + return container + + def _switch_page(self, idx: int) -> None: + self._stack.setCurrentIndex(idx) + if idx == 1: + self._refresh_favourites() + elif idx == 2: + self._refresh_history() + elif idx == 3: + self._refresh_stats() + + def _toggle_sidebar(self) -> None: + """Collapse/expand the sidebar with smooth animation.""" + self._sidebar_collapsed = not self._sidebar_collapsed + + # Animate width + target = ( + self._sidebar_collapsed_width + if self._sidebar_collapsed + else self._sidebar_expanded_width + ) + self._sidebar_anim.stop() + self._sidebar_anim.setStartValue(self._sidebar.width()) + self._sidebar_anim.setEndValue(target) + self._sidebar_anim.start() + + # Toggle nav button labels + show = not self._sidebar_collapsed + for btn in self._nav_group.buttons(): + if isinstance(btn, NavButton): + btn.show_label(show) + + self._toggle_btn.setText("▸" if self._sidebar_collapsed else "☰") + self._toggle_btn.setToolTip( + "Expand sidebar" if self._sidebar_collapsed else "Collapse sidebar" + ) + + # ── Settings ── + + def _open_settings(self) -> None: + dlg = SettingsDialog(main_window=self, parent=self) + dlg.exec() + + # ── Search ── + + @Slot(str) + def _on_search_text_changed(self, text: str) -> None: + self._search_timer.stop() + if text.strip(): + self._search_timer.start() + else: + # Restore full list when search text is cleared + idx = self._stack.currentIndex() + if idx == 1: + self._refresh_favourites() + elif idx == 2: + self._refresh_history() + else: + self._search_page.clear() + + @Slot() + def _do_search(self) -> None: + query = self._search_input.text().strip() + if not query: + return + + # Context-aware: filter local lists for history/favourites + idx = self._stack.currentIndex() + if idx == 1: + self._filter_list(self._fav_page, query) + return + elif idx == 2: + self._filter_list(self._hist_page, query) + return + + # Normal YouTube search + self._search_page.clear() + self._search_page.show_loading() + + # Clean up any previous search thread + if self._search_thread is not None and self._search_thread.isRunning(): + self._search_thread.quit() + self._search_thread.wait(500) + + self._search_thread = QThread(self) + self._search_worker = SearchWorker(query) + self._search_worker.moveToThread(self._search_thread) + self._search_thread.started.connect(self._search_worker.run) + self._search_worker.results_ready.connect(self._on_search_results) + self._search_worker.results_ready.connect(self._search_thread.quit) + self._search_worker.failed.connect(self._search_thread.quit) + self._search_thread.finished.connect(self._search_thread.deleteLater) + # Clear the Python reference when the C++ thread is done so that + # a subsequent search doesn't crash on a deleted QThread object. + self._search_thread.finished.connect( + lambda: setattr(self, "_search_thread", None) + ) + self._search_thread.start() + + @Slot(dict) + def _on_search_results(self, results: dict) -> None: + videos_raw = results.get("videos", []) + songs_raw = results.get("songs", []) + + # Augment each dict with its favourite state before handing off + # to the search page so that SongItemWidget shows the correct star. + def _set_fav(song: dict) -> dict: + song["_is_fav"] = self.db.is_favourite(song.get("videoId", "")) if song.get("videoId") else False + return song + + videos = [_set_fav(dict(s)) for s in videos_raw] + songs = [_set_fav(dict(s)) for s in songs_raw] + + self._search_page.display_results( + videos, songs, + play_callback=self._play_video, + fav_callback=self._on_fav_toggled, + queue_next_callback=self._queue_next, + ) + + def _filter_list(self, page, query: str) -> None: + """Filter a page's song list by title/artist match.""" + lst = page._list + lst.clear() + all_songs = getattr(page, "_all_songs", []) + query_lower = query.lower() + + matched = [] + for song in all_songs: + title = (song.get("title") or "").lower() + artists_str = " ".join( + a.get("name", "") for a in (song.get("artists") or []) + ).lower() + if query_lower in title or query_lower in artists_str: + matched.append(song) + + if not matched: + placeholder = QListWidgetItem("No results found.") + placeholder.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + lst.addItem(placeholder) + return + + for song in matched: + is_fav = song.get("_is_fav", False) + widget = SongItemWidget(song, is_fav=is_fav) + widget.play_requested.connect(self._play_video) + widget.fav_toggled.connect(self._on_fav_toggled) + widget.queue_next_requested.connect(self._queue_next) + item = QListWidgetItem(lst) + item.setSizeHint(widget.sizeHint()) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsSelectable) + lst.addItem(item) + lst.setItemWidget(item, widget) + + # ── Playback ── + + def _find_song_for_video_id(self, video_id: str): + """Look up the song dict for *video_id* from the current page.""" + page = self._stack.currentWidget() + song = self._find_in_list_widget(page, video_id) + if song is not None: + return song + return self._find_in_search_page(page, video_id) + + @staticmethod + def _find_in_list_widget(page, video_id: str): + """Search a QListWidget-based page (Favourites / History).""" + lst = getattr(page, '_list', None) + if not isinstance(lst, QListWidget): + return None + for i in range(lst.count()): + item = lst.item(i) + w = lst.itemWidget(item) + if w and getattr(w, "_video_id", "") == video_id: + return getattr(w, "_song", None) + return None + + @staticmethod + def _find_in_search_page(page, video_id: str): + """Search the SearchPage's results layout.""" + if not hasattr(page, '_results_layout'): + return None + for i in range(page._results_layout.count()): + item = page._results_layout.itemAt(i) + if item is None: + continue + w = item.widget() + if isinstance(w, SongItemWidget) and w._video_id == video_id: + return getattr(w, "_song", None) + return None + + @Slot(str) + def _play_video(self, video_id: str) -> None: + song = self._find_song_for_video_id(video_id) + if song is None: + song = {"videoId": video_id, "title": "Loading…", "artists": []} + else: + self._fetch_watch_playlist(video_id) + self.player.play(song) + + @Slot(str) + def _queue_next(self, video_id: str) -> None: + """Queue a song to play immediately after the current one.""" + song = self._find_song_for_video_id(video_id) + if song is not None: + self.player.queue_next(song) + + def _fetch_watch_playlist(self, video_id: str) -> None: + from ytmusicapi import YTMusic + try: + yt = YTMusic() + wp = yt.get_watch_playlist(videoId=video_id, limit=25) + tracks = wp.get("tracks", []) + if not isinstance(tracks, list): + tracks = [] + queued = [] + for t in tracks: + if not isinstance(t, dict): + continue + vid = t.get("videoId") + if not vid or vid == video_id: + continue + raw_thumb = t.get("thumbnail") + if isinstance(raw_thumb, str): + thumb_url = raw_thumb + elif isinstance(raw_thumb, list): + thumb_url = _best_thumbnail(raw_thumb) + else: + thumb_url = "" + queued.append({ + "videoId": vid, + "title": t.get("title", ""), + "artists": _norm_artists(t.get("artists")), + "album": t.get("album"), + "duration": 0, + "duration_label": t.get("length", "?"), + "thumbnail": thumb_url, + "_from_watch_playlist": True, + }) + if queued: + self.player.queue_list(queued) + except Exception as exc: + log.warning("Watch playlist fetch failed: %s", exc) + + # ── Event handlers ── + + @Slot(dict) + def _on_song_started(self, song: dict) -> None: + vid = song.get("videoId", "") + if vid: + self.db.record_play( + vid, + song.get("title", ""), + _norm_artists(song.get("artists")), + song.get("album"), + song.get("duration", 0), + song.get("thumbnail", ""), + ) + self.rpc.update_song(song) + if self.player.get_queue_length() == 0: + self._fetch_watch_playlist(vid) + + @Slot(str) + def _on_song_ended(self, video_id: str) -> None: + # Don't clear presence if there are more queued songs + if self.player.get_queue_length() == 0 and not self.player.get_current(): + self.rpc.clear() + + @Slot(str) + def _on_playback_state_changed(self, state: str) -> None: + """Handle Discord Rich Presence on playback state changes.""" + current = self.player.get_current() + if state == "playing" and current: + self.rpc.update_song(current, reset_start=True) + elif state == "paused" and current: + self.rpc.clear() + elif state == "stopped": + # Only clear if no more songs queued (transitional stop during + # queue advance should not clear presence — next song will restore it) + if self.player.get_queue_length() == 0: + self.rpc.clear() + + @Slot(dict) + def _on_player_error(self, err: dict) -> None: + log.error("Player error: %s", err.get("error", "unknown")) + + @Slot(int) + def _on_player_seeked(self, position_ms: int) -> None: + """Recalculate Discord RPC start timestamp after a seek.""" + self.rpc.seek_to(position_ms) + + # ── Favourites ── + + def _refresh_favourites(self) -> None: + lst = self._fav_page._list + lst.clear() + rows = self.db.get_favourites() + if not rows: + lst.addItem("No favourites yet.") + self._fav_page._all_songs = [] + return + songs_data = [] + for r in rows: + song = { + "videoId": r["video_id"], + "title": r["title"], + "artists": json.loads(r["artists"]) if r["artists"] else [], + "album": json.loads(r["album"]) if r["album"] else None, + "duration": r["duration"], + "duration_label": _fmt_ms(r["duration"] * 1000) if r["duration"] else "?", + "thumbnail": r["thumbnail"] or "", + "_is_fav": True, + } + songs_data.append(song) + widget = SongItemWidget(song, is_fav=True) + widget.play_requested.connect(self._play_video) + widget.fav_toggled.connect(self._on_fav_toggled) + widget.queue_next_requested.connect(self._queue_next) + item = QListWidgetItem(lst) + item.setSizeHint(widget.sizeHint()) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsSelectable) + lst.addItem(item) + lst.setItemWidget(item, widget) + self._fav_page._all_songs = songs_data + + @Slot(str, bool) + def _on_fav_toggled(self, video_id: str, new_state: bool) -> None: + self.db.set_favourite(video_id, new_state) + + # ── History ── + + def _refresh_history(self) -> None: + lst = self._hist_page._list + lst.clear() + rows = self.db.get_history(50) + if not rows: + lst.addItem("No play history yet.") + self._hist_page._all_songs = [] + return + songs_data = [] + for r in rows: + song = { + "videoId": r["video_id"], + "title": r["title"], + "artists": json.loads(r["artists"]) if r["artists"] else [], + "album": json.loads(r["album"]) if r["album"] else None, + "duration": r["duration"], + "duration_label": _fmt_ms(r["duration"] * 1000) if r["duration"] else "?", + "thumbnail": r["thumbnail"] or "", + "_is_fav": bool(r["is_favourite"]), + } + songs_data.append(song) + widget = SongItemWidget(song, is_fav=bool(r["is_favourite"])) + widget.play_requested.connect(self._play_video) + widget.fav_toggled.connect(self._on_fav_toggled) + widget.queue_next_requested.connect(self._queue_next) + item = QListWidgetItem(lst) + item.setSizeHint(widget.sizeHint()) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsSelectable) + lst.addItem(item) + lst.setItemWidget(item, widget) + self._hist_page._all_songs = songs_data + + # ── Stats ── + + def _refresh_stats(self) -> None: + stats = self.db.get_stats() + self._stats_page.set_stats(stats) + + # ── Window state tracking ─────────────────────────────────────────────── + + def changeEvent(self, event: QEvent) -> None: + """Primary (synchronous) window-state change detection. + + Called immediately when the window is minimised, restored, + maximised, etc. Fallback polling (_poll_minimized) handles + the rare platforms where this event is unreliable. + """ + if event.type() == QEvent.Type.WindowStateChange: + self._on_window_state_changed() + super().changeEvent(event) + + def _on_window_state_changed(self) -> None: + """Called from changeEvent or polling fallback when minimize + state may have changed.""" + now = self.isMinimized() or not self.isVisible() + + if now == self._minimized: + return + self._minimized = now + + self._visualizer.set_minimized(now) + label = "minimised" if now else "restored" + log.info("Window %s — UI animations %s", label, "suspended" if now else "resumed") + + def _poll_minimized(self) -> None: + """Fallback poll for platforms where WindowStateChange events + are unreliable (certain Linux window managers / Wayland). + + Runs every 500 ms. When minimised the visualizer timer is + stopped and audio buffer FFT processing is skipped — the + biggest CPU savers in the UI. + """ + try: + self._on_window_state_changed() + except Exception as exc: + log.error("_poll_minimized error: %s", exc) + + # ── Cleanup ── + + def resizeEvent(self, event) -> None: + """Position playback bar and visualizer at the bottom. + + Visualizer (44 px) overlaps the content area for a nice visual + effect but is transparent to mouse events so sidebar buttons + remain clickable. The glass bar (86 px) sits below it. + """ + super().resizeEvent(event) + cw = self.centralWidget() + w = cw.width() + h = cw.height() + + # Visualizer at the top of the playback area (overlaps content) + self._visualizer.setGeometry(0, h - 130, w, 44) + + # Glass bar below the visualizer + self._playback.setGeometry(0, h - 86, w, 86) + self._playback.raise_() + + def closeEvent(self, event) -> None: + # Flush any pending volume save before closing + self._playback._flush_volume() + self.player.shutdown() + self.rpc.stop() + self.db.close() + super().closeEvent(event) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Entry point +# ═══════════════════════════════════════════════════════════════════════════════ + + +def run_gui(extra_qt_log_rules: str = "") -> None: + app = QApplication(sys.argv) + app.setStyle("Fusion") + app.setApplicationName("Tunetti") + app.setOrganizationName("Tunetti") + + # Apply Qt logging rules programmatically — the QT_LOGGING_RULES env-var + # approach is fragile across Qt versions and platforms. + if extra_qt_log_rules: + QLoggingCategory.setFilterRules(extra_qt_log_rules) + + window = TunettiWindow() + window.show() + sys.exit(app.exec()) diff --git a/main.py b/main.py new file mode 100644 index 0000000..58188d8 --- /dev/null +++ b/main.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Tunetti – YouTube Music Player. + +Usage: + python main.py # normal mode + TUNETTI_VERBOSE=1 python main.py # full debug output +""" + +import logging +import os +import sys + + +# ── Detect verbose mode from env ──────────────────────────────────────────── +VERBOSE = os.environ.get("TUNETTI_VERBOSE", "").strip() in ("1", "true", "yes") + + +_LOG_LEVEL = logging.DEBUG if VERBOSE else logging.INFO +_LOG_FORMAT = ( + "%(asctime)s.%(msecs)03d %(levelname).1s %(name)s %(message)s" + if VERBOSE else + "%(levelname).1s %(name)s %(message)s" +) + +logging.basicConfig( + level=_LOG_LEVEL, + format=_LOG_FORMAT, + datefmt="%H:%M:%S", + stream=sys.stderr, +) + + +# Suppress noisy Qt FFmpeg warnings — these fire constantly when +# YouTube streams end prematurely (expected behaviour with short-lived +# streaming tokens). The env-var approach is unreliable across Qt +# versions, so we set filter rules *after* QApplication is created. +# In verbose mode the filters are omitted so you can see FFmpeg internals. +_QT_LOG_RULES = "" if VERBOSE else """\ +qt.multimedia.ffmpeg.demuxer=false +qt.multimedia.ffmpeg.io=false +qt.multimedia.ffmpeg.tls=false +qt.multimedia.backend=false +qt.multimedia=false +""" + + +def main() -> None: + from gui import run_gui + + # Propagate verbose flag to the player module so yt-dlp also + # produces debug output. + if VERBOSE: + import player as _player_mod + _player_mod.VERBOSE = True + log = logging.getLogger("tunetti") + log.debug("Verbose mode enabled — yt-dlp and Qt FFmpeg logging active") + + run_gui(extra_qt_log_rules=_QT_LOG_RULES) + + +if __name__ == "__main__": + main() diff --git a/music_db.py b/music_db.py new file mode 100644 index 0000000..0725339 --- /dev/null +++ b/music_db.py @@ -0,0 +1,225 @@ +"""SQLite-backed database for song history, favourites, and play stats.""" + +import sqlite3 +import threading +from datetime import datetime, timezone +from typing import Optional + + +class MusicDB: + """Thread-safe database layer for Tunetti.""" + + def __init__(self, db_path: str): + self._lock = threading.Lock() + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA foreign_keys=ON") + self._create_tables() + + # ------------------------------------------------------------------ + # Schema + # ------------------------------------------------------------------ + def _create_tables(self) -> None: + with self._lock: + self.conn.executescript(""" + CREATE TABLE IF NOT EXISTS songs ( + video_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + artists TEXT NOT NULL, -- JSON array of {name, id} + album TEXT, -- JSON {name, id} or NULL + duration INTEGER NOT NULL DEFAULT 0, + thumbnail TEXT, + is_favourite INTEGER NOT NULL DEFAULT 0, + play_count INTEGER NOT NULL DEFAULT 0, + first_played TIMESTAMP, + last_played TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS play_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + video_id TEXT NOT NULL REFERENCES songs(video_id), + played_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_history_video + ON play_history(video_id); + CREATE INDEX IF NOT EXISTS idx_history_date + ON play_history(played_at); + CREATE INDEX IF NOT EXISTS idx_songs_fav + ON songs(is_favourite); + """) + self.conn.commit() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _upsert_song(self, video_id: str, title: str, artists: str, + album: Optional[str], duration: int, + thumbnail: Optional[str]) -> None: + now = datetime.now(timezone.utc).isoformat() + self.conn.execute(""" + INSERT INTO songs (video_id, title, artists, album, duration, + thumbnail, first_played, last_played) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(video_id) DO UPDATE SET + title = excluded.title, + artists = excluded.artists, + album = excluded.album, + duration = excluded.duration, + thumbnail = excluded.thumbnail, + last_played = excluded.last_played + """, (video_id, title, artists, album, duration, thumbnail, now, now)) + self.conn.execute(""" + UPDATE songs SET play_count = play_count + 1 WHERE video_id = ? + """, (video_id,)) + + def _get_song_row(self, video_id: str) -> Optional[sqlite3.Row]: + cur = self.conn.execute( + "SELECT * FROM songs WHERE video_id = ?", (video_id,)) + return cur.fetchone() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def record_play(self, video_id: str, title: str, + artists: list[dict], album: Optional[dict] = None, + duration: int = 0, + thumbnail: Optional[str] = None) -> None: + """Record that a song was played (upsert + history row).""" + import json + artists_json = json.dumps(artists, ensure_ascii=False) + album_json = json.dumps(album) if album else None + with self._lock: + self._upsert_song(video_id, title, artists_json, + album_json, duration, thumbnail) + self.conn.execute( + "INSERT INTO play_history (video_id) VALUES (?)", (video_id,)) + self.conn.commit() + + def toggle_favourite(self, video_id: str) -> bool: + """Toggle the favourite flag for a song. Returns the new state.""" + with self._lock: + row = self._get_song_row(video_id) + if row is None: + return False + new_val = 0 if row["is_favourite"] else 1 + self.conn.execute( + "UPDATE songs SET is_favourite = ? WHERE video_id = ?", + (new_val, video_id)) + self.conn.commit() + return bool(new_val) + + def set_favourite(self, video_id: str, value: bool) -> bool: + """Explicitly set favourite state. Returns True if song exists.""" + with self._lock: + row = self._get_song_row(video_id) + if row is None: + return False + self.conn.execute( + "UPDATE songs SET is_favourite = ? WHERE video_id = ?", + (1 if value else 0, video_id)) + self.conn.commit() + return True + + def get_favourites(self) -> list[sqlite3.Row]: + """Return all favourited songs ordered by last played desc.""" + with self._lock: + cur = self.conn.execute(""" + SELECT * FROM songs + WHERE is_favourite = 1 + ORDER BY last_played DESC + """) + return cur.fetchall() + + def is_favourite(self, video_id: str) -> bool: + """Check if a song is favourited.""" + with self._lock: + row = self._get_song_row(video_id) + return bool(row and row["is_favourite"]) + + def get_history(self, limit: int = 50) -> list[sqlite3.Row]: + """Return recent play history with song details. + + Each video_id appears at most once (keeps the most recent play). + """ + with self._lock: + cur = self.conn.execute(""" + SELECT h.played_at, + s.video_id, s.title, s.artists, s.album, + s.duration, s.thumbnail, s.is_favourite + FROM play_history h + JOIN songs s ON s.video_id = h.video_id + WHERE h.id IN ( + SELECT MAX(id) FROM play_history GROUP BY video_id + ) + ORDER BY h.id DESC + LIMIT ? + """, (limit,)) + return cur.fetchall() + + def get_stats(self) -> dict: + """Return aggregate listening statistics.""" + with self._lock: + cur = self.conn.execute(""" + SELECT COUNT(DISTINCT video_id) AS unique_songs, + SUM(play_count) AS total_plays + FROM songs + """) + total = cur.fetchone() + + cur = self.conn.execute(""" + SELECT COUNT(*) AS fav_count FROM songs WHERE is_favourite = 1 + """) + favs = cur.fetchone() + + cur = self.conn.execute(""" + SELECT video_id, title, artists, thumbnail, play_count + FROM songs ORDER BY play_count DESC LIMIT 10 + """) + top = cur.fetchall() + + cur = self.conn.execute(""" + SELECT s.title, s.artists, h.played_at + FROM play_history h + JOIN songs s ON s.video_id = h.video_id + ORDER BY h.played_at DESC LIMIT 5 + """) + recent = cur.fetchall() + + cur = self.conn.execute(""" + SELECT date(played_at) AS day, COUNT(*) AS plays + FROM play_history + GROUP BY day ORDER BY day DESC LIMIT 30 + """) + by_day = cur.fetchall() + + return { + "unique_songs": total["unique_songs"], + "total_plays": total["total_plays"], + "favourites": favs["fav_count"], + "top_played": [ + { + "video_id": r["video_id"], + "title": r["title"], + "artists": r["artists"], + "thumbnail": r["thumbnail"] or "", + "play_count": r["play_count"] + } + for r in top + ], + "recent": [ + { + "title": r["title"], + "artists": r["artists"], + "played_at": r["played_at"] + } + for r in recent + ], + "plays_by_day": [ + {"day": r["day"], "plays": r["plays"]} for r in by_day + ], + } + + def close(self) -> None: + self.conn.close() diff --git a/player.py b/player.py new file mode 100644 index 0000000..074aa88 --- /dev/null +++ b/player.py @@ -0,0 +1,864 @@ +"""Audio player using QMediaPlayer + yt-dlp with queue and local-file playback. + +Instead of streaming YouTube audio URLs directly (which expire within seconds +and cause "Connection reset by peer" / "Demuxing failed" errors), we download +each song to a temporary file and play from the local filesystem. This is +reliable because QMediaPlayer never touches the network directly. + +Architecture +------------ +A single persistent ``DownloadWorker`` lives on a background ``QThread`` for +the entire player lifetime. Work is sent via Qt signals — no thread +creation/teardown, no signal-disconnect dances, no ``quit()``/``wait()`` +timeouts. Every download is tagged with a monotonically-increasing +``task_id``; stale results (from superseded requests) are simply discarded. + +State machine:: + + IDLE ──▶ LOADING ──▶ READY ──▶ PLAYING ──▶ ENDED + ▲ │ │ │ ▲ │ + │ ▼ │ │ │ │ + │ FAILED ───────┘ ▼ │ │ + └───────────────────────────────── PAUSED │ + │ ▲ │ + └──┘ │ + │ + (queue empty)─┘ + +Set ``TEST_MODE = True`` below to bypass yt-dlp and generate a short test-tone +WAV instead — useful for UI development without network access. +""" + +import logging +import os +import struct +import tempfile +import atexit +import math +import shutil +import threading +from typing import Optional + +import yt_dlp +from PySide6.QtCore import QObject, QThread, Signal, Slot, QUrl +from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput + +log = logging.getLogger("tunetti.player") + +# ── User-settable flags ──────────────────────────────────────────────────── +TEST_MODE: bool = False # skip yt-dlp, use local test-tone WAV instead +VERBOSE: bool = False # enable yt-dlp debug output + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Temp-file housekeeping +# ═══════════════════════════════════════════════════════════════════════════════ + +_TUNETTI_TMP: str | None = None + + +def _tmp_dir() -> str: + """Return a scoped temp directory that lives for the process lifetime.""" + global _TUNETTI_TMP + if _TUNETTI_TMP is None: + _TUNETTI_TMP = tempfile.mkdtemp(prefix="tunetti_") + atexit.register(shutil.rmtree, _TUNETTI_TMP, ignore_errors=True) + return _TUNETTI_TMP + + +def _stale_files(video_id: str) -> list[str]: + """Return paths of existing non-temp files for *video_id*, newest first.""" + td = _tmp_dir() + result = [] + for fn in os.listdir(td): + if fn.startswith(video_id) and ".temp." not in fn: + path = os.path.join(td, fn) + if os.path.isfile(path) and os.path.getsize(path) > 0: + result.append(path) + result.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return result + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Test-tone generator (used when TEST_MODE = True) +# ═══════════════════════════════════════════════════════════════════════════════ + +def _generate_test_wav(path: str, duration_s: int = 15) -> None: + """Write a 440 Hz sine wave to *path* as a 16-bit mono WAV file.""" + sample_rate = 44100 + num_samples = sample_rate * duration_s + frequency = 440.0 + amplitude = 0.3 + + with open(path, "wb") as f: + data_size = num_samples * 2 + f.write(b"RIFF") + f.write(struct.pack(" None: + """Entry point — invoked on the worker thread.""" + self._task_id = task_id + self._cancelled = False + + video_id = song.get("videoId") or song.get("video_id", "") + temp_path: Optional[str] = None + + try: + # Keep the stale cleanup at the start of each new download. + for stale in _stale_files(video_id): + try: + os.unlink(stale) + except OSError: + pass + + # ── TEST MODE ──────────────────────────────────────────── + if TEST_MODE: + wav_path = os.path.join(_tmp_dir(), f"{video_id}.wav") + _generate_test_wav(wav_path) + resolved = dict(song) + resolved["_local_path"] = wav_path + resolved["duration"] = 15 + resolved["thumbnail"] = "" + resolved["title"] = song.get("title", f"Test {video_id}") + with self._lock: + if not self._cancelled: + self.download_succeeded.emit(task_id, resolved) + return + + # ── Real download ──────────────────────────────────────── + tmpl = os.path.join(_tmp_dir(), f"{video_id}.%(ext)s") + ydl_opts: dict = { + "quiet": not VERBOSE, + "no_warnings": not VERBOSE, + "verbose": VERBOSE, + "format": "worstaudio/worst", + "outtmpl": tmpl, + "noprogress": not VERBOSE, + "extract_flat": False, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info( + f"https://music.youtube.com/watch?v={video_id}", + download=True, + ) + + # Construct the expected path from the output template. + # The outtmpl is /.%(ext)s and yt-dlp resolves + # %(ext)s from the downloaded format. We avoid relying on + # info["requested_downloads"][0]["filepath"] because that key + # may be set *before* post-processor fixup renames the file. + actual_ext = info.get("ext", "") + expected_path = os.path.join(_tmp_dir(), f"{video_id}.{actual_ext}") if actual_ext else "" + + temp_path = self._resolve_temp_path(video_id, expected_path) + if not temp_path: + raise RuntimeError( + f"Download produced empty/missing file for {video_id}" + ) + + resolved = self._build_resolved_dict(song, info, temp_path) + + with self._lock: + if not self._cancelled: + self.download_succeeded.emit(task_id, resolved) + + except Exception as exc: + log.error("Download error for %s: %s", video_id, exc) + self._cleanup_path(temp_path) + with self._lock: + if not self._cancelled: + self.download_failed.emit(task_id) + + def _resolve_temp_path(self, video_id: str, expected_path: str) -> Optional[str]: + """Resolve the actual temp file path after a download. + + First checks the *expected_path* (built from the extension yt-dlp reported), + then falls back to scanning the temp directory for stale files. + """ + # Debug: list every file in the temp dir for this video_id + td = _tmp_dir() + try: + all_matches = [ + (fn, os.path.getsize(os.path.join(td, fn))) + for fn in os.listdir(td) + if fn.startswith(video_id) + ] + all_matches.sort() + log.debug("Temp files for %s: %s", video_id, all_matches) + except OSError as exc: + log.debug("Temp dir listing failed: %s", exc) + + if expected_path: + log.debug("Checking expected path: %s", expected_path) + if expected_path and os.path.isfile(expected_path) and os.path.getsize(expected_path) > 0: + return expected_path + + # Fallback: scan the temp directory for the most recent valid file. + candidates = _stale_files(video_id) + log.debug("_stale_files fallback for %s: %s", video_id, candidates) + if candidates: + return candidates[0] + + return None + + @staticmethod + def _build_resolved_dict(song: dict, info: dict, temp_path: str) -> dict: + """Build the resolved song dict with local path and metadata.""" + resolved = dict(song) + resolved["_local_path"] = temp_path + resolved["duration"] = info.get("duration") or song.get("duration", 0) + resolved["thumbnail"] = info.get("thumbnail") or song.get("thumbnail", "") + resolved["title"] = info.get("title", song.get("title", "")) + return resolved + + @Slot() + def _do_cancel(self) -> None: + """Mark the current download as cancelled.""" + with self._lock: + self._cancelled = True + + @staticmethod + def _cleanup_path(path: Optional[str]) -> None: + if path and os.path.isfile(path): + try: + os.unlink(path) + except OSError: + pass + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Search worker +# ═══════════════════════════════════════════════════════════════════════════════ + +class SearchWorker(QObject): + """Runs a ytmusicapi search in a background thread. + + Searches both songs and videos, returning categorised results + as a dict with ``"songs"`` and ``"videos"`` keys each containing + a list of normalised song dicts. + """ + + results_ready = Signal(dict) + failed = Signal(str) + + def __init__(self, query: str): + super().__init__() + self._query = query + + @Slot() + def run(self) -> None: + if TEST_MODE: + dummy = [ + {"videoId": f"dummy_{i}", "title": f"Test Song {i}", + "artists": [{"name": f"Artist {i}"}], + "album": None, "duration_seconds": 15, + "duration": "0:15", "thumbnails": [], + "videoType": "MUSIC_VIDEO_TYPE_ATV", + "resultType": "song"} + for i in range(5) + ] + self.results_ready.emit({"songs": dummy, "videos": []}) + return + + from ytmusicapi import YTMusic + try: + yt = YTMusic() + # Always search both categories so the UI can display them + # side by side with a "Videos" and "Songs" header. + raw_songs = yt.search(self._query, filter="songs", limit=20) + raw_videos = yt.search(self._query, filter="videos", limit=20) + + songs = [ + _build_song_dict(r) for r in raw_songs if r.get("videoId") + ] + videos = [ + _build_song_dict(r) for r in raw_videos if r.get("videoId") + ] + self.results_ready.emit({"songs": songs, "videos": videos}) + except Exception as exc: + log.error("Search failed: %s", exc) + self.failed.emit(str(exc)) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Main AudioPlayer +# ═══════════════════════════════════════════════════════════════════════════════ + +class AudioPlayer(QObject): + """QObject-based audio player wrapping QMediaPlayer with local-file playback. + + Every song is downloaded to a temporary file before playing. A single + persistent ``DownloadWorker`` on a background ``QThread`` handles all + downloads — no thread creation/teardown, no signal-disconnect races, no + ``quit()``/``wait()`` timeouts. + + Signals + ------- + loading(video_id) + song_started(song_dict) + song_ended(video_id) + paused(song_dict) + resumed(song_dict) + error(dict) + position_changed(ms: int) + duration_changed(ms: int) + playback_state_changed(state: str) — "playing"|"paused"|"stopped" + """ + + loading = Signal(str) + song_started = Signal(dict) + song_ended = Signal(str) + paused = Signal(dict) + resumed = Signal(dict) + error = Signal(dict) + position_changed = Signal(int) + duration_changed = Signal(int) + playback_state_changed = Signal(str) + seeked = Signal(int) # position_ms after seek + + def __init__(self, parent: Optional[QObject] = None): + super().__init__(parent) + + # ── QMediaPlayer ────────────────────────────────────────────── + self._player = QMediaPlayer(self) + self._audio_output = QAudioOutput(self) + self._player.setAudioOutput(self._audio_output) + self._audio_output.setVolume(0.5) + + self._player.mediaStatusChanged.connect(self._on_media_status) + self._player.positionChanged.connect(self._on_position) + self._player.durationChanged.connect(self._on_duration) + self._player.playbackStateChanged.connect(self._on_playback_state) + self._player.errorOccurred.connect(self._on_player_error_occurred) + + # ── Queue & state ───────────────────────────────────────────── + self._queue: list[dict] = [] + self._current: Optional[dict] = None + self._loop_mode = False + + # ── Download worker (single persistent thread) ──────────────── + self._next_task_id = 0 + # The task ID that is "current" — results with other IDs are stale. + self._active_task_id: Optional[int] = None + # The task ID of the in-progress prefetch (if any). + self._prefetch_task_id: Optional[int] = None + # Cache of prefetched results: video_id → resolved dict with _local_path. + self._prefetch_cache: dict[str, dict] = {} + + self._worker_thread = QThread(self) + self._worker = DownloadWorker() + self._worker.moveToThread(self._worker_thread) + + # Connect worker signals (cross-thread → queued delivery). + self._worker.download_requested.connect(self._worker._do_download) + self._worker.cancel_requested.connect(self._worker._do_cancel) + self._worker.download_succeeded.connect(self._on_download_succeeded) + self._worker.download_failed.connect(self._on_download_failed) + self._worker_thread.start() + + # ═══════════════════════════════════════════════════════════════════════ + # Public API + # ═══════════════════════════════════════════════════════════════════════ + + # ── Volume ────────────────────────────────────────────────────────────── + + def set_volume(self, percent: int) -> None: + self._audio_output.setVolume(max(0, min(100, percent)) / 100.0) + + def volume(self) -> int: + return int(self._audio_output.volume() * 100) + + # ── Seek ──────────────────────────────────────────────────────────────── + + def seek(self, ms: int) -> None: + self._player.setPosition(max(0, ms)) + self.seeked.emit(max(0, ms)) + + # ── Play / Queue ──────────────────────────────────────────────────────── + + def play(self, song: dict) -> None: + """Clear the queue and play *song* immediately.""" + self._cleanup_temp_files() + self._queue.clear() + self._prefetch_cache.clear() + self._loop_mode = False + self._request_download(song) + + def queue_song(self, song: dict) -> None: + self._queue.append(song) + if self._current is None and self._active_task_id is None: + self._pop_and_play() + + def queue_next(self, song: dict) -> None: + """Insert *song* to play immediately after the current one.""" + self._queue.insert(0, song) + + def queue_list(self, songs: list[dict]) -> None: + self._queue.extend(songs) + + def skip(self) -> None: + """Skip to the next song.""" + # Cancel any in-progress download (the skip is our new intent). + self._cancel_active_downloads() + self._player.stop() + self._advance() + + def stop_playback(self) -> None: + """Soft stop: halt playback, clear queue, cancel downloads.""" + self._cancel_active_downloads() + self._cleanup_temp_files() + self._queue.clear() + self._prefetch_cache.clear() + self._loop_mode = False + self._current = None + self._player.stop() + self.playback_state_changed.emit("stopped") + + @Slot() + def _pop_and_play(self) -> None: + """Pop the next song from the queue and start downloading it. + + If it was already prefetched, the download is instant (already cached). + """ + if not self._queue: + return + song = self._queue.pop(0) + vid = song.get("videoId") or song.get("video_id", "") + cached = self._prefetch_cache.pop(vid, None) + if cached is not None: + # Prefetched — play directly without downloading. + self._start_playback(cached) + else: + self._request_download(song) + + # ── Pause / Resume ────────────────────────────────────────────────────── + + def pause(self) -> None: + if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self._player.pause() + + def resume(self) -> None: + if self._player.playbackState() == QMediaPlayer.PlaybackState.PausedState: + self._player.play() + + def toggle_pause(self) -> bool: + state = self._player.playbackState() + if state == QMediaPlayer.PlaybackState.PlayingState: + self._player.pause() + return True + elif state == QMediaPlayer.PlaybackState.PausedState: + self._player.play() + return False + return False + + def is_paused(self) -> bool: + return self._player.playbackState() == QMediaPlayer.PlaybackState.PausedState + + def is_playing(self) -> bool: + return self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState + + def is_stopped(self) -> bool: + return self._player.playbackState() == QMediaPlayer.PlaybackState.StoppedState + + # ── Queue introspection ──────────────────────────────────────────────── + + def get_current(self) -> Optional[dict]: + return self._current + + def get_queue(self) -> list[dict]: + return list(self._queue) + + def get_queue_length(self) -> int: + return len(self._queue) + + def get_loop(self) -> bool: + return self._loop_mode + + def set_loop(self, enabled: bool) -> None: + self._loop_mode = enabled + + # ═══════════════════════════════════════════════════════════════════════ + # Download lifecycle + # ═══════════════════════════════════════════════════════════════════════ + + def _request_download(self, song: dict) -> None: + """Request a download via the persistent worker. + + Cancels any in-progress download and prefetch first, then assigns a + fresh task ID and emits the request signal. + """ + vid = song.get("videoId") or song.get("video_id", "") + + # Guard: don't re-download what's already playing. + current_vid = ( + (self._current or {}).get("videoId") + or (self._current or {}).get("video_id", "") + ) + if vid and vid == current_vid: + log.debug("Ignoring duplicate download request for %s", vid) + return + + # Fast path: already has a local file (e.g. from prefetch cache). + if song.get("_local_path"): + self._start_playback(song) + return + + # Cancel any in-flight work — this is a new intent. + self._cancel_active_downloads() + + task_id = self._next_task_id + self._next_task_id += 1 + self._active_task_id = task_id + + self.loading.emit(vid) + self._worker.download_requested.emit(task_id, song) + + def _cancel_active_downloads(self) -> None: + """Tell the worker to discard its current work and forget about it.""" + if self._active_task_id is not None or self._prefetch_task_id is not None: + self._worker.cancel_requested.emit() + self._active_task_id = None + self._prefetch_task_id = None + + @Slot(int, dict) + def _on_download_succeeded(self, task_id: int, song: dict) -> None: + """Called on the main thread when a download succeeds. + + Checks *task_id* against ``_active_task_id`` and ``_prefetch_task_id`` + to discard stale results from superseded requests. + """ + log.debug("Download succeeded: task_id=%d, active=%s, prefetch=%s", + task_id, self._active_task_id, self._prefetch_task_id) + + # ── Stale check for active download ────────────────────────── + if task_id == self._active_task_id: + self._active_task_id = None + + local_path = song.get("_local_path") + if not local_path or not os.path.isfile(local_path): + log.error("Download succeeded but file missing: %s", local_path) + self.error.emit({ + "video_id": song.get("videoId", ""), + "error": "Downloaded file went missing", + }) + self._try_next_song() + return + + self._start_playback(song) + return + + # ── Stale check for prefetch download ──────────────────────── + if task_id == self._prefetch_task_id: + self._prefetch_task_id = None + + vid = song.get("videoId") or song.get("video_id", "") + # Only cache if the song is still in the active queue. + if any( + (s.get("videoId") or s.get("video_id", "")) == vid + for s in self._queue + ): + log.debug("Caching prefetched result for %s (task %d)", vid, task_id) + self._prefetch_cache[vid] = song + else: + log.debug("Prefetched %s no longer in queue, discarding", vid) + return + + # ── Otherwise: truly stale — discard ──────────────────────────── + log.debug("Discarding stale download result for task %d (task_id=%d, active=%s, prefetch=%s)", + task_id, task_id, self._active_task_id, self._prefetch_task_id) + + @Slot(int) + def _on_download_failed(self, task_id: int) -> None: + """Called on the main thread when a download fails.""" + log.debug("Download failed: task_id=%d, active=%s, prefetch=%s", + task_id, self._active_task_id, self._prefetch_task_id) + + if task_id == self._active_task_id: + self._active_task_id = None + log.warning("Download failed for task %d", task_id) + self.error.emit({ + "video_id": "", + "error": "Failed to resolve audio URL", + }) + self._try_next_song() + elif task_id == self._prefetch_task_id: + self._prefetch_task_id = None + log.debug("Prefetch failed for task %d (harmless)", task_id) + else: + log.debug("Ignoring stale download failure for task %d", task_id) + + # ── Playback ─────────────────────────────────────────────────────── + + def _start_playback(self, song: dict) -> None: + """Begin playing a resolved song (already has ``_local_path``). + + Always called on the main thread. + """ + # Remove the previous song's temp file BEFORE updating _current. + self._cleanup_current_temp() + + local_path = song.get("_local_path", "") + log.debug("Starting playback: path=%s, exists=%s, size=%s", + local_path, + os.path.isfile(local_path) if local_path else "N/A", + os.path.getsize(local_path) if local_path and os.path.isfile(local_path) else "N/A") + + self._player.stop() + self._current = song + + self._player.setSource(QUrl.fromLocalFile(local_path)) + self._player.play() + self.song_started.emit(song) + + # Kick off prefetch for the next queued song. + self._start_prefetch() + + # ═══════════════════════════════════════════════════════════════════════ + # End-of-song handling + # ═══════════════════════════════════════════════════════════════════════ + + def _on_media_status(self, status: QMediaPlayer.MediaStatus) -> None: + if status == QMediaPlayer.MediaStatus.EndOfMedia: + self._advance() + elif status == QMediaPlayer.MediaStatus.InvalidMedia: + if self._current is None: + return + if self._player.playbackState() == QMediaPlayer.PlaybackState.StoppedState: + return + self.error.emit({ + "video_id": (self._current or {}).get("videoId", ""), + "error": "Invalid media", + }) + # Don't call _advance here — the player will go to stopped state, + # and _on_playback_state will handle advancing if appropriate. + # But let's advance immediately for invalid media. + self._advance() + + def _advance(self) -> None: + """Move to the next song (or stop if the queue is empty). + + This is the single exit point from an actively-playing song. + It handles loop re-queuing, song_ended emission, and queue popping. + """ + if self._current is None: + return + + finished_song = self._current + finished_vid = finished_song.get("videoId") or finished_song.get( + "video_id", "" + ) + self._current = None + + self.song_ended.emit(finished_vid) + + # Re-queue for loop mode. + if self._loop_mode: + self._queue.insert(0, finished_song) + + self._try_next_song() + + def _try_next_song(self) -> None: + """Attempt to play the next song, or stop if the queue is empty.""" + if self._queue: + self._pop_and_play() + else: + self.playback_state_changed.emit("stopped") + + # ── Position / duration / state ──────────────────────────────────── + + @Slot(int) + def _on_position(self, ms: int) -> None: + self.position_changed.emit(ms) + + @Slot(int) + def _on_duration(self, ms: int) -> None: + self.duration_changed.emit(ms) + + @Slot(QMediaPlayer.PlaybackState) + def _on_playback_state(self, state: QMediaPlayer.PlaybackState) -> None: + if state == QMediaPlayer.PlaybackState.PlayingState: + self.playback_state_changed.emit("playing") + if self._current: + self.resumed.emit(self._current) + elif state == QMediaPlayer.PlaybackState.PausedState: + self.playback_state_changed.emit("paused") + if self._current: + self.paused.emit(self._current) + else: + self.playback_state_changed.emit("stopped") + + # ── errorOccurred handler ───────────────────────────────────────── + + @Slot(QMediaPlayer.Error, str) + def _on_player_error_occurred( + self, error: QMediaPlayer.Error, error_string: str + ) -> None: + """Handle QMediaPlayer error signals.""" + if error in (QMediaPlayer.Error.NetworkError, QMediaPlayer.Error.ResourceError): + vid = (self._current or {}).get("videoId", "") + log.warning("Player error (%s): %s — advancing", error, error_string) + self.error.emit({ + "video_id": vid, + "error": f"{error_string} (local file issue)", + }) + self._advance() + + # ── Prefetch ────────────────────────────────────────────────────────────── + + def _start_prefetch(self) -> None: + """Request a download of the next queued song (if any). + + Called after playback of the current song starts. Uses the same + persistent worker — no separate thread needed. + """ + if not self._queue: + return + + next_song = self._queue[0] + vid = next_song.get("videoId") or next_song.get("video_id", "") + + # Already cached or already downloading. + if vid in self._prefetch_cache: + return + if vid == self._prefetch_task_id: + return + # Don't prefetch if it's already the active download target. + if self._active_task_id is not None: + # The active download might be for this same song (e.g. if + # _request_download was called but is still in flight). Check by + # tracking task_id alone is enough — if there's an active download, + # the prefetch is redundant anyway. + return + + task_id = self._next_task_id + self._next_task_id += 1 + self._prefetch_task_id = task_id + + self._worker.download_requested.emit(task_id, next_song) + + # ═══════════════════════════════════════════════════════════════════════ + # Temp-file cleanup + # ═══════════════════════════════════════════════════════════════════════ + + def _cleanup_current_temp(self) -> None: + """Remove the temp file of the song we're about to replace.""" + if self._current is None: + return + lp = self._current.get("_local_path") + if lp and os.path.isfile(lp): + try: + os.unlink(lp) + except OSError: + pass + + def _cleanup_temp_files(self) -> None: + """Remove all temp files we know about (current + prefetch cache).""" + if self._current: + lp = self._current.get("_local_path") + if lp and os.path.isfile(lp): + try: + os.unlink(lp) + except OSError: + pass + for cached in self._prefetch_cache.values(): + lp = cached.get("_local_path") + if lp and os.path.isfile(lp): + try: + os.unlink(lp) + except OSError: + pass + + # ── Shutdown ────────────────────────────────────────────────────────────── + + def shutdown(self) -> None: + """Clean shutdown — stop playback, quit worker thread, remove temps.""" + self._player.stop() + self._cancel_active_downloads() + self._cleanup_temp_files() + self._queue.clear() + self._prefetch_cache.clear() + self._current = None + self._loop_mode = False + + # Tear down the persistent worker thread. + self._worker_thread.quit() + self._worker_thread.wait(3000) + # The worker and thread objects are owned by self (parented), so + # they'll be cleaned up by Qt when the AudioPlayer is destroyed. + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _best_thumbnail(thumbnails) -> str: + if not thumbnails: + return "" + if isinstance(thumbnails, str): + return thumbnails + if isinstance(thumbnails, list): + best = thumbnails[-1] + return best.get("url", "") if isinstance(best, dict) else str(best) + return str(thumbnails) + + +def _build_song_dict(search_result: dict) -> dict: + """Normalise a ytmusicapi result into our standard song dict.""" + return { + "videoId": search_result.get("videoId", ""), + "title": search_result.get("title", "Unknown"), + "artists": search_result.get("artists") or [], + "album": search_result.get("album"), + "duration": search_result.get("duration_seconds", 0), + "duration_label": search_result.get("duration", "?"), + "thumbnail": _best_thumbnail(search_result.get("thumbnails", [])), + "videoType": search_result.get("videoType", ""), + "resultType": search_result.get("resultType", "song"), + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..861d6e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +yt-dlp>=2024.0.0 +ytmusicapi>=1.0.0 +pypresence>=4.0.0 +PySide6>=6.5.0 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..77c5a19 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,4 @@ +sonar.projectKey=Tunetti +sonar.projectName=Tunetti +sonar.sources=. +sonar.language=py