All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m25s
2888 lines
110 KiB
Python
2888 lines
110 KiB
Python
"""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)
|
||
|
||
def show_error(self, message: str) -> None:
|
||
"""Show a temporary error message in the song info area."""
|
||
self._np_title.setText("⚠️ " + message)
|
||
self._np_artist.setText("")
|
||
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)
|
||
QTimer.singleShot(4000, lambda: self._clear_error())
|
||
|
||
def _clear_error(self) -> None:
|
||
"""Restore the "Not playing" text after an error."""
|
||
if self._player.is_stopped() and self._player.get_queue_length() == 0:
|
||
self._np_title.setText(_STYLE_NOT_PLAYING)
|
||
|
||
@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"))
|
||
self._playback.show_error(err.get("error", "Playback error"))
|
||
|
||
@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())
|