"""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())