Files
Tunetti/gui.py
NikkeDoy 31ceb040ed
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m25s
🐛 | Inform user if loading fails
2026-06-01 21:22:51 +03:00

2888 lines
110 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 CooleyTukey 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())