v0.2 - drop fingerprinting, add LUFS cart normalisation, playlist save/load, editable history with TXT+CSV export

This commit is contained in:
2026-05-08 17:19:11 +10:00
parent 0c8900ac36
commit 95f74d813f
19 changed files with 679 additions and 483 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
venv/
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
View File
@@ -6,8 +6,10 @@ import jack
import threading
import subprocess
import os
import pyloudnorm as pyln
LUFS_TARGET = -14.0
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
@@ -52,6 +54,18 @@ def load_audio(filepath: str, target_sr: int) -> np.ndarray:
return data
def normalize_lufs(audio: np.ndarray, sr: int,
target: float = LUFS_TARGET) -> np.ndarray:
meter = pyln.Meter(sr)
loudness = meter.integrated_loudness(audio.T)
if np.isinf(loudness):
return audio
gain_db = target - loudness
gain_linear = 10 ** (gain_db / 20.0)
normalized = audio * gain_linear
return np.clip(normalized, -1.0, 1.0)
class Track:
def __init__(self, filepath: str, audio: np.ndarray, sr: int):
self.filepath = filepath
@@ -260,6 +274,14 @@ class AudioEngine:
self.decks[deck_key].load(track)
threading.Thread(target=_load, daemon=True).start()
def load_cart_track(self, cart_key: str, filepath: str):
def _load():
audio = load_audio(filepath, self.sr)
audio = normalize_lufs(audio, self.sr)
track = Track(filepath, audio, self.sr)
self.decks[cart_key].load(track)
threading.Thread(target=_load, daemon=True).start()
def load_queue(self, deck_key: str, filepath: str):
def _load():
audio = load_audio(filepath, self.sr)
+1 -1
View File
@@ -156,7 +156,7 @@ class CartSlot(QFrame):
self.btn_eject.clicked.connect(self._eject)
def load_track(self, filepath: str):
self.engine.load_track(self.cart_key, filepath)
self.engine.load_cart_track(self.cart_key, filepath)
name = os.path.basename(filepath)
short = name[:28] + "..." if len(name) > 28 else name
self.track_label.setText(short)
+113 -47
View File
@@ -4,7 +4,7 @@ from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QSizePolicy
)
from PySide6.QtCore import Qt, QTimer, Signal, QRect
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QFont, QColor, QPainter, QPen
import numpy as np
import os
@@ -24,26 +24,18 @@ class WaveformWidget(QFrame):
}
""")
self.waveform_data = None # Shape: (width,) - RMS per pixel
self.position_pct = 0.0 # 0.0 to 1.0
self.waveform_data = None
self.position_pct = 0.0
self.warning_flash = False
def set_waveform(self, audio: np.ndarray):
"""
Downsample audio to fit widget width.
audio shape: (2, samples) stereo
"""
if audio is None or audio.shape[1] == 0:
self.waveform_data = None
self.update()
return
# Mix to mono
mono = np.mean(audio, axis=0)
# Downsample to widget width
width = self.width()
if width < 10:
width = 500 # default before widget is shown
mono = np.mean(audio, axis=0)
width = max(500, self.width())
samples_per_pixel = max(1, len(mono) // width)
num_pixels = len(mono) // samples_per_pixel
@@ -55,9 +47,9 @@ class WaveformWidget(QFrame):
self.update()
def set_position(self, position_pct: float):
"""Update playhead position (0.0 to 1.0)."""
self.position_pct = max(0.0, min(1.0, position_pct))
def set_position(self, position_pct: float, warning: bool = False):
self.position_pct = max(0.0, min(1.0, position_pct))
self.warning_flash = warning
self.update()
def paintEvent(self, event):
@@ -69,30 +61,39 @@ class WaveformWidget(QFrame):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect()
rect = self.rect().adjusted(2, 2, -2, -2)
width = rect.width()
height = rect.height()
# Scale waveform to fit height
max_val = np.max(self.waveform_data) if np.max(self.waveform_data) > 0 else 1.0
scaled = (self.waveform_data / max_val) * (height * 0.8)
# Draw waveform bars
pen = QPen(QColor("#00ff41"), 1)
painter.setPen(pen)
x_scale = width / len(self.waveform_data)
for i, rms in enumerate(self.waveform_data):
x = int(i * x_scale)
bar_h = int(scaled[i])
y_top = (height - bar_h) // 2
x = rect.x() + int(i * x_scale)
bar_h = int(scaled[i])
y_top = rect.y() + (height - bar_h) // 2
pct = i / len(self.waveform_data)
if pct < self.position_pct:
color = QColor("#004400")
elif pct > 0.85:
color = QColor("#ff0000") if self.warning_flash else QColor("#aa0000")
elif pct > 0.70:
color = QColor("#ffaa00")
else:
color = QColor("#00ff41")
pen = QPen(color, 1)
painter.setPen(pen)
painter.drawLine(x, y_top, x, y_top + bar_h)
# Draw playhead
playhead_x = int(self.position_pct * width)
playhead_x = rect.x() + int(self.position_pct * width)
pen = QPen(QColor("#ffffff"), 2)
painter.setPen(pen)
painter.drawLine(playhead_x, 0, playhead_x, height)
painter.drawLine(playhead_x, rect.y(), playhead_x, rect.y() + height)
class LCDDisplay(QFrame):
@@ -188,6 +189,23 @@ class LCDDisplay(QFrame):
f"color: {colour}; background: transparent;"
)
def flash_warning(self, active: bool):
"""Flash all time displays red when in warning zone."""
if active:
self.elapsed_label.setStyleSheet(
"color: #ff4444; background: transparent;"
)
self.remaining_label.setStyleSheet(
"color: #ff4444; background: transparent;"
)
else:
self.elapsed_label.setStyleSheet(
"color: #00ff41; background: transparent;"
)
self.remaining_label.setStyleSheet(
"color: #00cc33; background: transparent;"
)
def make_transport_button(text: str,
color: str = "#3a3a3a",
@@ -225,8 +243,13 @@ class DeckWidget(QWidget):
self.engine = engine
self.accent_color = accent_color
self.current_theme = None
self.flash_state = False
self._build_ui(label)
# Convert "DECK 1" to "DECK_001"
deck_num = label.split()[-1]
self.deck_label = f"DECK_{int(deck_num):03d}"
self._build_ui()
self._connect_signals()
self.timer = QTimer(self)
@@ -234,7 +257,12 @@ class DeckWidget(QWidget):
self.timer.timeout.connect(self._refresh_display)
self.timer.start()
def _build_ui(self, label: str):
self.flash_timer = QTimer(self)
self.flash_timer.setInterval(500)
self.flash_timer.timeout.connect(self._toggle_flash)
self.flash_timer.start()
def _build_ui(self):
self.setStyleSheet(f"""
QWidget {{
background-color: #1c1c1c;
@@ -247,21 +275,20 @@ class DeckWidget(QWidget):
root.setContentsMargins(10, 10, 10, 10)
root.setSpacing(8)
header = QLabel(label)
header.setFont(QFont("Arial", 13, QFont.Bold))
header.setStyleSheet(f"""
self.header = QLabel(self.deck_label)
self.header.setFont(QFont("Courier New", 24, QFont.Bold))
self.header.setStyleSheet(f"""
color: {self.accent_color};
background: transparent;
border: none;
padding: 2px;
padding: 4px;
""")
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
self.header.setAlignment(Qt.AlignCenter)
root.addWidget(self.header)
self.lcd = LCDDisplay()
root.addWidget(self.lcd)
# Waveform display
self.waveform = WaveformWidget()
root.addWidget(self.waveform)
@@ -286,30 +313,38 @@ class DeckWidget(QWidget):
root.addLayout(transport)
def apply_theme(self, theme: dict):
"""Apply theme colours to deck widget borders and LCD."""
self.current_theme = theme
border_color = theme['border']
bg_color = theme['bg2']
self.setStyleSheet(f"""
QWidget {{
background-color: {theme['bg2']};
border: 2px solid {theme['border']};
background-color: {bg_color};
border: 2px solid {border_color};
border-radius: 6px;
}}
""")
self.lcd.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg']};
border: 2px solid {theme['border']};
border: 2px solid {border_color};
border-radius: 4px;
}}
""")
self.waveform.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg']};
border: 1px solid {theme['border']};
border: 1px solid {border_color};
border-radius: 3px;
}}
""")
def _toggle_flash(self):
self.flash_state = not self.flash_state
def _connect_signals(self):
self.btn_play.clicked.connect(self._toggle_play)
self.btn_stop.clicked.connect(self._stop_eject)
@@ -322,7 +357,9 @@ class DeckWidget(QWidget):
self.lcd.update_track(name)
self.lcd.update_status("LOADED")
self.lcd.update_time("00:00", "-00:00")
# Build waveform when track loads
QTimer.singleShot(200, self._update_waveform)
def _update_waveform(self):
deck = self.engine.decks[self.deck_key]
if deck.track:
self.waveform.set_waveform(deck.track.audio)
@@ -428,7 +465,7 @@ class DeckWidget(QWidget):
if deck.queued:
deck.load(deck.queued)
self.lcd.update_track(os.path.splitext(os.path.basename(deck.queued.filepath))[0])
self.waveform.set_waveform(deck.track.audio)
QTimer.singleShot(200, self._update_waveform)
deck.queued = None
self.lcd.update_queue("")
self.lcd.update_status("LOADED")
@@ -453,9 +490,38 @@ class DeckWidget(QWidget):
deck.get_elapsed(),
deck.get_remaining()
)
# Update waveform position
position_pct = deck.position / deck.track.num_samples
self.waveform.set_position(position_pct)
remaining_sec = deck.track.duration - (deck.position / deck.track.sr)
# Warning if < 20 seconds OR < 15% remaining
in_warning = (remaining_sec < 20) or (position_pct > 0.85)
# Flash header AND all numbers if in warning zone and playing
if deck.playing and in_warning:
flash_color = '#ff4444' if self.flash_state else self.accent_color
# Flash header
self.header.setStyleSheet(f"""
color: {flash_color};
background: transparent;
border: none;
padding: 4px;
""")
# Flash LCD numbers
self.lcd.flash_warning(self.flash_state)
else:
# Normal colors
self.header.setStyleSheet(f"""
color: {self.accent_color};
background: transparent;
border: none;
padding: 4px;
""")
self.lcd.flash_warning(False)
self.waveform.set_position(position_pct, in_warning and self.flash_state)
if deck.queued:
self.lcd.update_queue(
-296
View File
@@ -1,296 +0,0 @@
# fingerprint_widget.py
import os
import json
import tempfile
import subprocess
import threading
import requests
import numpy as np
import soundfile as sf
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QLabel,
QPushButton, QProgressBar, QFrame
)
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QFont
class FingerprintWidget(QFrame):
result_ready = Signal(object)
error_ready = Signal(str)
def __init__(self, engine, api_key: str, parent=None):
super().__init__(parent)
self.engine = engine
self.acoustid_key = api_key.strip()
self.recording_seconds = 25
self.last_signal_check = False
self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setStyleSheet("""
QFrame {
background-color: #0d0d1a;
border: 2px solid #4444aa;
border-radius: 6px;
}
""")
self._build_ui()
self.result_ready.connect(self._show_result)
self.error_ready.connect(self._show_error)
self.signal_timer = QTimer(self)
self.signal_timer.setInterval(500)
self.signal_timer.timeout.connect(self._check_signal)
self.signal_timer.start()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(10, 8, 10, 8)
root.setSpacing(6)
header = QLabel("AUDIO FINGERPRINT")
header.setFont(QFont("Arial", 11, QFont.Bold))
header.setStyleSheet("color: #6666cc; background: transparent; border: none;")
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
port_info = QLabel("JACK: RadioPanel:fingerprint_L / R")
port_info.setFont(QFont("Courier New", 8))
port_info.setStyleSheet("color: #444488; background: transparent; border: none;")
port_info.setAlignment(Qt.AlignCenter)
root.addWidget(port_info)
self.signal_label = QLabel("● NO SIGNAL")
self.signal_label.setFont(QFont("Courier New", 14, QFont.Bold))
self.signal_label.setStyleSheet("color: #330000; background: transparent; border: none;")
self.signal_label.setAlignment(Qt.AlignCenter)
root.addWidget(self.signal_label)
self.progress = QProgressBar()
self.progress.setRange(0, self.recording_seconds * 10)
self.progress.setValue(0)
self.progress.setTextVisible(False)
self.progress.setFixedHeight(8)
self.progress.setStyleSheet("""
QProgressBar {
border: 1px solid #4444aa;
border-radius: 3px;
background: #0a0a1a;
}
QProgressBar::chunk {
background-color: #6666cc;
}
""")
self.progress.hide()
root.addWidget(self.progress)
self.status_label = QLabel("READY")
self.status_label.setFont(QFont("Courier New", 10, QFont.Bold))
self.status_label.setStyleSheet("color: #444488; background: transparent; border: none;")
self.status_label.setAlignment(Qt.AlignCenter)
root.addWidget(self.status_label)
result_frame = QFrame()
result_frame.setStyleSheet("""
QFrame {
background-color: #0a0a1a;
border: 1px solid #333366;
border-radius: 3px;
}
""")
result_layout = QVBoxLayout(result_frame)
result_layout.setContentsMargins(6, 6, 6, 6)
self.result_artist = QLabel("Artist: -")
self.result_artist.setFont(QFont("Courier New", 10, QFont.Bold))
self.result_artist.setStyleSheet("color: #555588;")
self.result_title = QLabel("Title: -")
self.result_title.setFont(QFont("Courier New", 11, QFont.Bold))
self.result_title.setStyleSheet("color: #555588;")
result_layout.addWidget(self.result_artist)
result_layout.addWidget(self.result_title)
root.addWidget(result_frame)
self.btn_fingerprint = QPushButton("FINGERPRINT")
self.btn_fingerprint.setFixedHeight(44)
self.btn_fingerprint.setFont(QFont("Arial", 12, QFont.Bold))
self.btn_fingerprint.setStyleSheet("""
QPushButton {
background-color: #4444aa;
color: white;
border: 1px solid #6666cc;
border-radius: 4px;
}
QPushButton:hover { background-color: #5555bb; }
QPushButton:pressed { background-color: #333399; }
QPushButton:disabled {
background-color: #222244;
color: #666688;
}
""")
self.btn_fingerprint.clicked.connect(self._start_fingerprint)
root.addWidget(self.btn_fingerprint)
root.addStretch()
def _check_signal(self):
if self.engine.recording:
return
levels = self.engine.fingerprint_levels
has_signal = (levels[0] > 0.01 or levels[1] > 0.01)
if has_signal != self.last_signal_check:
self.last_signal_check = has_signal
if has_signal:
self.signal_label.setText("● SIGNAL PRESENT")
self.signal_label.setStyleSheet("color: #00ff41; background: transparent;")
else:
self.signal_label.setText("● NO SIGNAL")
self.signal_label.setStyleSheet("color: #330000; background: transparent;")
def _start_fingerprint(self):
if not self.acoustid_key:
self.error_ready.emit("Set your AcoustID key in main.py first!")
return
if self.engine.recording:
return
self.btn_fingerprint.setEnabled(False)
self.progress.show()
self.progress.setValue(0)
self.status_label.setText("LISTENING...")
self.status_label.setStyleSheet("color: #ccaa00;")
self.signal_label.setText("● RECORDING...")
self.signal_label.setStyleSheet("color: #ccaa00;")
self.result_artist.setText("Artist: -")
self.result_title.setText("Title: -")
self.recording_timer = QTimer(self)
self.recording_timer.setInterval(100)
self.recording_timer.timeout.connect(self._update_progress)
self.recording_timer.start()
self.engine.start_recording(self.recording_seconds, self._on_recording_finished)
def _update_progress(self):
if not self.engine.recording:
self.recording_timer.stop()
self.progress.setValue(self.progress.maximum())
return
if self.engine.record_buffer is not None:
progress = self.engine.record_position / self.engine.record_target_frames
self.progress.setValue(int(progress * self.progress.maximum()))
def _on_recording_finished(self, audio_data: np.ndarray, sr: int):
self.recording_timer.stop()
self.status_label.setText("ANALYZING...")
self.status_label.setStyleSheet("color: #4488ff;")
self.signal_label.setText("● PROCESSING")
self.signal_label.setStyleSheet("color: #4488ff;")
threading.Thread(
target=self._fingerprint_thread,
args=(audio_data, sr),
daemon=True
).start()
def _fingerprint_thread(self, audio_data: np.ndarray, sr: int):
try:
result = self._identify_acoustid(audio_data, sr)
self.result_ready.emit(result)
except Exception as e:
err_str = str(e)
if "Invalid API key" in err_str:
err_str = "Invalid API Key"
elif "limit" in err_str.lower():
err_str = "Daily limit reached"
self.error_ready.emit(err_str)
def _identify_acoustid(self, audio_data: np.ndarray, sr: int) -> dict:
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
temp_path = f.name
try:
sf.write(temp_path, audio_data.T, sr, subtype='PCM_16')
result = subprocess.run(
['fpcalc', '-json', temp_path],
capture_output=True,
text=True,
timeout=15
)
if result.returncode != 0:
raise RuntimeError(f"fpcalc failed: {result.stderr}")
fp_data = json.loads(result.stdout)
fingerprint = fp_data['fingerprint']
duration = fp_data.get('duration', 0)
finally:
os.unlink(temp_path)
url = 'https://api.acoustid.org/v2/lookup'
params = {
'client': self.acoustid_key,
'fingerprint': fingerprint,
'duration': int(duration),
'meta': 'recordings'
}
response = requests.get(url, params=params, timeout=15)
data = response.json()
if data.get('status') != 'ok':
err = data.get('error', {}).get('message', 'Unknown')
raise RuntimeError(f"AcoustID: {err}")
results = data.get('results', [])
if not results:
return None
best = results[0]
recordings = best.get('recordings', [])
if not recordings:
return None
rec = recordings[0]
title = rec.get('title', 'Unknown')
artists = rec.get('artists', [])
artist = artists[0].get('name', 'Unknown') if artists else 'Unknown'
return {
'artist': artist,
'title': title,
'score': best.get('score', 0)
}
def _show_result(self, result):
self.btn_fingerprint.setEnabled(True)
self.progress.hide()
self.signal_label.setText("● DONE")
self.signal_label.setStyleSheet("color: #00aa33;")
if result is None:
self.status_label.setText("NO MATCH")
self.status_label.setStyleSheet("color: #aa4444;")
else:
self.status_label.setText(f"MATCH {result['score']:.0%}")
self.status_label.setStyleSheet("color: #00ff41;")
self.result_artist.setText(f"Artist: {result['artist']}")
self.result_title.setText(f"Title: {result['title']}")
self.result_artist.setStyleSheet("color: #cccccc;")
self.result_title.setStyleSheet("color: #00ff41;")
def _show_error(self, error: str):
self.btn_fingerprint.setEnabled(True)
self.progress.hide()
self.signal_label.setText("● ERROR")
self.signal_label.setStyleSheet("color: #aa4444;")
self.status_label.setText("ERROR")
self.status_label.setStyleSheet("color: #ff4444;")
self.result_artist.setText("Error:")
self.result_title.setText(error[:60])
self.result_artist.setStyleSheet("color: #aa4444;")
self.result_title.setStyleSheet("color: #aa4444;")
+298 -76
View File
@@ -1,11 +1,17 @@
# history_widget.py
import csv
import io
import os
import threading
import musicbrainzngs
from mutagen import File as MutagenFile
from PySide6.QtWidgets import (
QFrame, QVBoxLayout, QLabel,
QScrollArea, QWidget
QFrame, QVBoxLayout, QHBoxLayout, QLabel,
QScrollArea, QWidget, QPushButton, QDialog,
QLineEdit, QFormLayout, QDialogButtonBox,
QFileDialog
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont
@@ -18,11 +24,9 @@ def _read_file_tags(filepath: str) -> dict:
tags = MutagenFile(filepath, easy=True)
if tags is None:
return {}
def get(key):
val = tags.get(key)
return val[0] if val else None
return {
'artist': get('artist'),
'title': get('title'),
@@ -36,35 +40,34 @@ def _read_file_tags(filepath: str) -> dict:
def _lookup_musicbrainz(artist: str, title: str) -> dict:
try:
result = musicbrainzngs.search_recordings(
recording=title,
artist=artist,
limit=1
recording=title, artist=artist, limit=1
)
recordings = result.get('recording-list', [])
if not recordings:
return {}
rec = recordings[0]
rec = recordings[0]
releases = rec.get('release-list', [])
if not releases:
return {}
release = releases[0]
album = release.get('title', None)
year = None
date = release.get('date', '')
if date:
year = date[:4]
return {'album': album, 'year': year}
return {
'album': release.get('title'),
'year': date[:4] if date else None,
}
except Exception:
return {}
class HistoryEntry(QFrame):
def __init__(self, index: int, filepath: str, metadata: dict, parent=None):
deleted = Signal(object)
def __init__(self, index: int, filepath: str, metadata: dict,
parent=None):
super().__init__(parent)
self._index = index
self._filepath = filepath
self._metadata = metadata
self.setFrameStyle(QFrame.Box)
self.setStyleSheet("""
@@ -75,51 +78,140 @@ class HistoryEntry(QFrame):
}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(2)
root = QHBoxLayout(self)
root.setContentsMargins(8, 6, 8, 6)
root.setSpacing(8)
text_col = QVBoxLayout()
text_col.setSpacing(2)
artist = metadata.get('artist') or 'Unknown Artist'
title = metadata.get('title') or self._filename(filepath)
top_row = QLabel(f"{index}. {artist}{title}")
top_row.setFont(QFont("Arial", 11, QFont.Bold))
top_row.setStyleSheet("color: #00ff41; background: transparent; border: none;")
top_row.setWordWrap(True)
layout.addWidget(top_row)
top = QLabel(f"{index}. {artist}{title}")
top.setFont(QFont("Arial", 11, QFont.Bold))
top.setStyleSheet(
"color: #00ff41; background: transparent; border: none;"
)
top.setWordWrap(True)
text_col.addWidget(top)
album = metadata.get('album') or ''
year = metadata.get('year') or ''
parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p]
sub_parts = []
if album:
sub_parts.append(album)
if year:
sub_parts.append(year[:4])
if sub_parts:
sub_row = QLabel(" " + " · ".join(sub_parts))
sub_row.setFont(QFont("Arial", 9))
sub_row.setStyleSheet(
if parts:
sub = QLabel(" " + " · ".join(parts))
sub.setFont(QFont("Arial", 9))
sub.setStyleSheet(
"color: #888888; background: transparent; border: none;"
)
sub_row.setWordWrap(True)
layout.addWidget(sub_row)
sub.setWordWrap(True)
text_col.addWidget(sub)
root.addLayout(text_col, stretch=1)
self._del_btn = QPushButton("")
self._del_btn.setFixedSize(24, 24)
self._del_btn.setFont(QFont("Arial", 10, QFont.Bold))
self._del_btn.setStyleSheet("""
QPushButton {
background-color: #5a1a1a;
color: #ff4444;
border: 1px solid #444;
border-radius: 12px;
}
QPushButton:hover { background-color: #7a2a2a; }
""")
self._del_btn.setToolTip("Remove from history")
self._del_btn.clicked.connect(lambda: self.deleted.emit(self))
root.addWidget(self._del_btn, alignment=Qt.AlignTop)
def _filename(self, filepath: str) -> str:
import os
if not filepath:
return "Unknown"
name = os.path.basename(filepath)
return os.path.splitext(name)[0]
@property
def filepath(self) -> str:
return self._filepath
@property
def metadata(self) -> dict:
return self._metadata
class AddEntryDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Add to History")
self.setFixedWidth(380)
self.setStyleSheet("""
QDialog {
background-color: #1e1e1e;
color: #cccccc;
border: 2px solid #444;
border-radius: 6px;
}
QLabel {
color: #aaaaaa;
background: transparent;
border: none;
}
QLineEdit {
background-color: #111;
color: #00ff41;
border: 1px solid #333;
border-radius: 3px;
padding: 6px;
font-family: Courier New;
font-size: 12px;
}
QPushButton {
background-color: #2a2a2a;
color: #cccccc;
border: 1px solid #444;
border-radius: 3px;
padding: 6px 16px;
font-weight: bold;
}
QPushButton:hover { background-color: #444; }
""")
layout = QFormLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
self._artist = QLineEdit()
self._artist.setPlaceholderText("e.g. The Beatles")
layout.addRow("Artist:", self._artist)
self._title = QLineEdit()
self._title.setPlaceholderText("e.g. Hey Jude")
layout.addRow("Title:", self._title)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Ok).setText("Add")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addRow(buttons)
def values(self) -> dict:
return {
'artist': self._artist.text().strip(),
'title': self._title.text().strip(),
}
class HistoryWidget(QFrame):
_metadata_ready = Signal(str, dict)
def __init__(self, parent=None):
super().__init__(parent)
self._history = []
self._pending = {}
self._history = []
self._pending = {}
self._entry_index = 0
self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setStyleSheet("""
@@ -138,21 +230,43 @@ class HistoryWidget(QFrame):
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(6)
# Header
header = QLabel("PLAY HISTORY")
header.setFont(QFont("Arial", 11, QFont.Bold))
header.setStyleSheet("""
color: #336633;
background: transparent;
border: none;
padding: 2px;
""")
header.setStyleSheet(
"color: #336633; background: transparent; border: none; padding: 2px;"
)
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll.setStyleSheet("""
# Toolbar
bar = QHBoxLayout()
bar.setSpacing(4)
self._add_btn = QPushButton("+ ADD")
self._add_btn.setFixedHeight(28)
self._add_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._add_btn.setStyleSheet("""
QPushButton {
background-color: #2a4a2a;
color: #00ff41;
border: 1px solid #336633;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #3a5a3a; }
""")
self._add_btn.clicked.connect(self._add_manual_entry)
bar.addWidget(self._add_btn)
bar.addStretch()
root.addLayout(bar)
# Scroll area
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._scroll.setStyleSheet("""
QScrollArea {
border: none;
background: transparent;
@@ -168,18 +282,56 @@ class HistoryWidget(QFrame):
}
""")
self.list_widget = QWidget()
self.list_widget.setStyleSheet("background: transparent;")
self.list_layout = QVBoxLayout(self.list_widget)
self.list_layout.setContentsMargins(0, 0, 0, 0)
self.list_layout.setSpacing(0)
self.list_layout.addStretch()
self._list_widget = QWidget()
self._list_widget.setStyleSheet("background: transparent;")
self._list_layout = QVBoxLayout(self._list_widget)
self._list_layout.setContentsMargins(0, 0, 0, 0)
self._list_layout.setSpacing(0)
self._list_layout.addStretch()
self.scroll.setWidget(self.list_widget)
root.addWidget(self.scroll)
self._scroll.setWidget(self._list_widget)
root.addWidget(self._scroll)
# Export bar
export_bar = QHBoxLayout()
export_bar.setSpacing(4)
self._export_txt_btn = QPushButton("EXPORT TXT")
self._export_txt_btn.setFixedHeight(28)
self._export_txt_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._export_txt_btn.setStyleSheet("""
QPushButton {
background-color: #2a3a2a;
color: #aaaaaa;
border: 1px solid #444;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #3a4a3a; }
""")
self._export_txt_btn.clicked.connect(self._export_txt)
self._export_csv_btn = QPushButton("EXPORT CSV")
self._export_csv_btn.setFixedHeight(28)
self._export_csv_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._export_csv_btn.setStyleSheet("""
QPushButton {
background-color: #2a3a2a;
color: #aaaaaa;
border: 1px solid #444;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #3a4a3a; }
""")
self._export_csv_btn.clicked.connect(self._export_csv)
export_bar.addWidget(self._export_txt_btn)
export_bar.addWidget(self._export_csv_btn)
export_bar.addStretch()
root.addLayout(export_bar)
def apply_theme(self, theme: dict):
"""Apply theme to history border."""
self.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg2']};
@@ -189,41 +341,69 @@ class HistoryWidget(QFrame):
""")
def add_track(self, filepath: str):
index = len(self._history) + 1
idx = self._entry_index + 1
metadata = _read_file_tags(filepath)
self._history.append((filepath, metadata))
self._insert_entry(index, filepath, metadata)
self._entry_index = idx
entry = self._insert_entry(idx, filepath, metadata)
if not metadata.get('artist') or not metadata.get('album'):
self._pending[filepath] = index
self._pending[filepath] = idx
threading.Thread(
target=self._enrich_metadata,
args=(filepath, metadata),
daemon=True
).start()
def _insert_entry(self, index: int, filepath: str, metadata: dict):
def _add_manual_entry(self):
dlg = AddEntryDialog(self)
if dlg.exec() != QDialog.Accepted:
return
vals = dlg.values()
if not vals['artist'] and not vals['title']:
return
idx = self._entry_index + 1
metadata = {
'artist': vals['artist'] or 'Unknown Artist',
'title': vals['title'] or 'Unknown Title',
'album': None,
'year': None,
}
self._history.append(("", metadata))
self._entry_index = idx
self._insert_entry(idx, "", metadata)
def _insert_entry(self, index: int, filepath: str,
metadata: dict) -> HistoryEntry:
entry = HistoryEntry(index, filepath, metadata)
self.list_layout.insertWidget(0, entry)
self.scroll.verticalScrollBar().setValue(0)
entry.deleted.connect(self._delete_entry)
self._list_layout.insertWidget(0, entry)
self._scroll.verticalScrollBar().setValue(0)
return entry
def _delete_entry(self, entry: HistoryEntry):
idx = entry._index - 1
if 0 <= idx < len(self._history):
self._history.pop(idx)
entry.deleteLater()
self._rebuild_list()
def _enrich_metadata(self, filepath: str, existing: dict):
artist = existing.get('artist')
title = existing.get('title')
if not artist or not title:
return
mb_data = _lookup_musicbrainz(artist, title)
if not mb_data:
return
merged = dict(existing)
if not merged.get('album') and mb_data.get('album'):
merged['album'] = mb_data['album']
if not merged.get('year') and mb_data.get('year'):
merged['year'] = mb_data['year']
self._metadata_ready.emit(filepath, merged)
def _on_metadata_ready(self, filepath: str, metadata: dict):
@@ -234,15 +414,57 @@ class HistoryWidget(QFrame):
self._rebuild_list()
def _rebuild_list(self):
while self.list_layout.count() > 1:
item = self.list_layout.takeAt(0)
while self._list_layout.count() > 1:
item = self._list_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
for i, (filepath, metadata) in enumerate(reversed(self._history)):
index = len(self._history) - i
entry = HistoryEntry(index, filepath, metadata)
self.list_layout.insertWidget(
self.list_layout.count() - 1,
entry
entry.deleted.connect(self._delete_entry)
self._list_layout.insertWidget(
self._list_layout.count() - 1, entry
)
def _iter_entries(self):
for i, (fp, md) in enumerate(reversed(self._history)):
idx = len(self._history) - i
artist = md.get('artist') or 'Unknown Artist'
title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown')
album = md.get('album') or ''
year = md.get('year') or ''
yield idx, artist, title, album, year
def _export_txt(self):
path, _ = QFileDialog.getSaveFileName(
self, "Export History (TXT)",
os.path.expanduser("~/play_history.txt"),
"Text Files (*.txt)"
)
if not path:
return
with open(path, 'w') as f:
for idx, artist, title, album, year in self._iter_entries():
f.write(f"{idx}. {artist}{title}")
if album or year:
parts = [p for p in [album,
year[:4] if year and len(year) >= 4 else year]
if p]
if parts:
f.write(f"\n {' · '.join(parts)}")
f.write("\n")
def _export_csv(self):
path, _ = QFileDialog.getSaveFileName(
self, "Export History (CSV)",
os.path.expanduser("~/play_history.csv"),
"CSV Files (*.csv)"
)
if not path:
return
with open(path, 'w', newline='') as f:
w = csv.writer(f)
w.writerow(["#", "Artist", "Title", "Album", "Year"])
for idx, artist, title, album, year in self._iter_entries():
w.writerow([idx, artist, title, album, year])
+72 -48
View File
@@ -1,15 +1,12 @@
# lufs_meter_widget.py
from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel
from PySide6.QtCore import Qt, QTimer, QRect
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient
from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel, QWidget
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
class LUFSMeterWidget(QFrame):
"""
Vertical LUFS meter for broadcast monitoring.
EBU R128 standard: Target -23 LUFS ±1 LU (Loudness Units).
"""
"""Vertical LUFS meter with -14 LUFS target for streaming/broadcast."""
def __init__(self, engine, parent=None):
super().__init__(parent)
@@ -29,7 +26,7 @@ class LUFSMeterWidget(QFrame):
self.timer = QTimer(self)
self.timer.setInterval(100)
self.timer.timeout.connect(self.update)
self.timer.timeout.connect(self._update_meter)
self.timer.start()
def _build_ui(self):
@@ -42,74 +39,101 @@ class LUFSMeterWidget(QFrame):
header.setAlignment(Qt.AlignCenter)
layout.addWidget(header)
# Meter canvas
self.meter_canvas = QFrame()
self.meter_canvas.setStyleSheet("""
QFrame {
background-color: #000000;
border: 1px solid #333333;
border-radius: 3px;
}
""")
self.meter_canvas = MeterCanvas(self.engine)
layout.addWidget(self.meter_canvas, stretch=1)
# Numeric readout
self.readout = QLabel("-70.0")
self.readout.setFont(QFont("Courier New", 12, QFont.Bold))
self.readout.setStyleSheet("color: #00ff41; background: transparent; border: none;")
self.readout.setAlignment(Qt.AlignCenter)
layout.addWidget(self.readout)
def paintEvent(self, event):
super().paintEvent(event)
def _update_meter(self):
lufs = self.engine.lufs_current
self.readout.setText(f"{lufs:.1f}")
self.meter_canvas.update()
painter = QPainter(self.meter_canvas)
rect = self.meter_canvas.rect().adjusted(2, 2, -2, -2)
# LUFS scale: -70 (bottom) to 0 (top)
# Target zone: -24 to -22 (±1 LU around -23)
lufs_range = 70.0 # -70 to 0
class MeterCanvas(QWidget):
"""Custom painted LUFS bar with -14 LUFS target."""
if lufs < -70:
lufs = -70
if lufs > 0:
lufs = 0
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self.setStyleSheet("""
QWidget {
background-color: #000000;
border: 1px solid #333333;
border-radius: 3px;
}
""")
self.setMinimumHeight(200)
fill_pct = (lufs + 70) / lufs_range
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(3, 3, -3, -3)
lufs = self.engine.lufs_current
# Scale: -50 LUFS (bottom) to 0 (top) - makes -14 more readable
lufs_min = -50
lufs_max = 0
lufs_range = lufs_max - lufs_min
lufs = max(lufs_min, min(lufs_max, lufs))
fill_pct = (lufs - lufs_min) / lufs_range
fill_h = int(rect.height() * fill_pct)
# Draw gradient meter
gradient = QLinearGradient(0, rect.height(), 0, 0)
# Color zones optimized for -14 LUFS target
gradient = QLinearGradient(0, rect.bottom(), 0, rect.top())
# Color zones (broadcast standard)
if lufs < -30:
gradient.setColorAt(0, QColor("#003300")) # Very quiet - dark green
gradient.setColorAt(1, QColor("#005500"))
elif lufs < -24:
gradient.setColorAt(0, QColor("#00aa00")) # Quiet - green
# Very quiet - dark green
gradient.setColorAt(0, QColor("#002200"))
gradient.setColorAt(1, QColor("#004400"))
elif lufs < -18:
# Quiet - green
gradient.setColorAt(0, QColor("#00aa00"))
gradient.setColorAt(1, QColor("#00dd00"))
elif lufs < -22:
gradient.setColorAt(0, QColor("#00ff00")) # Target zone - bright green
elif lufs < -12:
# Target zone -18 to -12 LUFS (±4 LU around -14) - bright green
gradient.setColorAt(0, QColor("#00ff00"))
gradient.setColorAt(1, QColor("#00ff00"))
elif lufs < -9:
gradient.setColorAt(0, QColor("#ffcc00")) # Getting hot - yellow
elif lufs < -6:
# Getting hot - yellow
gradient.setColorAt(0, QColor("#ffcc00"))
gradient.setColorAt(1, QColor("#ffaa00"))
else:
gradient.setColorAt(0, QColor("#ff0000")) # Too hot - red
# Too hot - red
gradient.setColorAt(0, QColor("#ff0000"))
gradient.setColorAt(1, QColor("#ff0000"))
painter.fillRect(
rect.x(),
rect.y() + rect.height() - fill_h,
rect.bottom() - fill_h,
rect.width(),
fill_h,
gradient
)
# Draw target line at -23 LUFS
target_y = rect.y() + int(rect.height() * (1.0 - ((-23 + 70) / lufs_range)))
painter.setPen(QColor("#ffffff"))
painter.drawLine(rect.x(), target_y, rect.x() + rect.width(), target_y)
# Draw reference lines
painter.setPen(QPen(QColor("#ffffff"), 1))
# Target line at -14 LUFS (main target)
target_pct = ((-14 - lufs_min) / lufs_range)
target_y = rect.bottom() - int(rect.height() * target_pct)
painter.drawLine(rect.x(), target_y, rect.right(), target_y)
# Upper boundary at -12 LUFS (dotted)
painter.setPen(QPen(QColor("#888888"), 1, Qt.DotLine))
upper_pct = ((-12 - lufs_min) / lufs_range)
upper_y = rect.bottom() - int(rect.height() * upper_pct)
painter.drawLine(rect.x(), upper_y, rect.right(), upper_y)
# Lower boundary at -16 LUFS (dotted)
lower_pct = ((-16 - lufs_min) / lufs_range)
lower_y = rect.bottom() - int(rect.height() * lower_pct)
painter.drawLine(rect.x(), lower_y, rect.right(), lower_y)
painter.end()
+101 -2
View File
@@ -8,6 +8,7 @@ from PySide6.QtWidgets import (
)
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QFont, QColor, QAction
import json
import os
@@ -264,6 +265,17 @@ class BasePlaylistPanel(QWidget):
else:
item.setForeground(QColor("#cccccc"))
def get_filepaths(self, lst: QListWidget) -> list:
paths = []
for i in range(lst.count()):
item = lst.item(i)
if isinstance(item, PlaylistItem):
paths.append(item.filepath)
return paths
def clear_list(self, lst: QListWidget):
lst.clear()
# ─────────────────────────────────────────
# TRACK PLAYLIST PANEL
@@ -574,18 +586,58 @@ class CartPlaylistPanel(BasePlaylistPanel):
# ─────────────────────────────────────────
class PlaylistWidget(QWidget):
PLAYLIST_JSON_FILTER = "JSON Files (*.json)"
def __init__(self, engine, deck_widgets: dict,
cart_slots: list, parent=None):
super().__init__(parent)
self.engine = engine
# Expandable - no fixed width
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMinimumWidth(200)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
root.setSpacing(4)
# Toolbar
bar = QHBoxLayout()
bar.setSpacing(4)
self._save_btn = QPushButton("SAVE")
self._save_btn.setFixedHeight(28)
self._save_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._save_btn.setStyleSheet("""
QPushButton {
background-color: #2a4a2a;
color: #aaaaaa;
border: 1px solid #444;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #3a5a3a; }
""")
self._save_btn.clicked.connect(self._save_playlist)
self._load_btn = QPushButton("LOAD")
self._load_btn.setFixedHeight(28)
self._load_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._load_btn.setStyleSheet("""
QPushButton {
background-color: #3a2a2a;
color: #aaaaaa;
border: 1px solid #444;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #4a3a3a; }
""")
self._load_btn.clicked.connect(self._load_playlist)
bar.addWidget(self._save_btn)
bar.addWidget(self._load_btn)
bar.addStretch()
root.addLayout(bar)
splitter = QSplitter(Qt.Vertical)
splitter.setStyleSheet("""
@@ -603,3 +655,50 @@ class PlaylistWidget(QWidget):
splitter.setSizes([600, 400])
root.addWidget(splitter)
def _save_playlist(self):
path, _ = QFileDialog.getSaveFileName(
self, "Save Playlist",
os.path.expanduser("~/playlist.json"),
self.PLAYLIST_JSON_FILTER
)
if not path:
return
data = {
"version": 1,
"tracks": self.track_panel.get_filepaths(self.track_panel.playlist),
"cart_tracks": self.cart_panel.get_filepaths(self.cart_panel.playlist),
}
with open(path, 'w') as f:
json.dump(data, f, indent=2)
def _load_playlist(self):
path, _ = QFileDialog.getOpenFileName(
self, "Load Playlist",
os.path.expanduser("~"),
self.PLAYLIST_JSON_FILTER
)
if not path:
return
try:
with open(path) as f:
data = json.load(f)
except Exception:
return
tracks = data.get("tracks", [])
cart = data.get("cart_tracks", [])
self.track_panel.playlist.clear()
for fp in tracks:
if os.path.exists(fp):
self.track_panel._add_item_to(self.track_panel.playlist, fp)
self.track_panel._update_count(self.track_panel.playlist,
self.track_panel.count_label)
self.cart_panel.playlist.clear()
for fp in cart:
if os.path.exists(fp):
self.cart_panel._add_item_to(self.cart_panel.playlist, fp)
self.cart_panel._update_count(self.cart_panel.playlist,
self.cart_panel.count_label)
+1 -1
View File
@@ -1,7 +1,7 @@
PySide6
numpy
soundfile
requests
pyloudnorm
mutagen
musicbrainzngs
python-rtmidi
+65 -9
View File
@@ -7,7 +7,7 @@ from datetime import datetime
THEMES = [
# Original 4
# Dark themes
{
'name': 'GREEN',
'accent': '#00ff41',
@@ -16,6 +16,7 @@ THEMES = [
'bg2': '#111111',
'border': '#1a3a1a',
'text': '#cccccc',
'is_light': False,
},
{
'name': 'AMBER',
@@ -25,6 +26,7 @@ THEMES = [
'bg2': '#111100',
'border': '#3a3000',
'text': '#ddddcc',
'is_light': False,
},
{
'name': 'BLUE',
@@ -34,6 +36,7 @@ THEMES = [
'bg2': '#001122',
'border': '#001a3a',
'text': '#ccd8dd',
'is_light': False,
},
{
'name': 'RED',
@@ -43,8 +46,8 @@ THEMES = [
'bg2': '#110000',
'border': '#3a0000',
'text': '#ddcccc',
'is_light': False,
},
# 10 New Bold Themes
{
'name': 'PURPLE',
'accent': '#cc00ff',
@@ -53,6 +56,7 @@ THEMES = [
'bg2': '#1a001a',
'border': '#3a003a',
'text': '#ddccdd',
'is_light': False,
},
{
'name': 'CYAN',
@@ -62,6 +66,7 @@ THEMES = [
'bg2': '#001a1a',
'border': '#003a3a',
'text': '#ccdddd',
'is_light': False,
},
{
'name': 'ORANGE',
@@ -71,6 +76,7 @@ THEMES = [
'bg2': '#1a1100',
'border': '#3a2200',
'text': '#ddccbb',
'is_light': False,
},
{
'name': 'PINK',
@@ -80,6 +86,7 @@ THEMES = [
'bg2': '#1a000a',
'border': '#3a0015',
'text': '#ddccdd',
'is_light': False,
},
{
'name': 'LIME',
@@ -89,6 +96,7 @@ THEMES = [
'bg2': '#111a00',
'border': '#223a00',
'text': '#ddddcc',
'is_light': False,
},
{
'name': 'GOLD',
@@ -98,6 +106,7 @@ THEMES = [
'bg2': '#1a1a0a',
'border': '#3a3a15',
'text': '#ddddbb',
'is_light': False,
},
{
'name': 'VIOLET',
@@ -107,6 +116,7 @@ THEMES = [
'bg2': '#0a0015',
'border': '#15002a',
'text': '#ccbbdd',
'is_light': False,
},
{
'name': 'TURQUOISE',
@@ -116,6 +126,7 @@ THEMES = [
'bg2': '#001a15',
'border': '#00382d',
'text': '#ccddda',
'is_light': False,
},
{
'name': 'CRIMSON',
@@ -125,6 +136,7 @@ THEMES = [
'bg2': '#1a0005',
'border': '#3a000a',
'text': '#ddcccc',
'is_light': False,
},
{
'name': 'CHARTREUSE',
@@ -134,6 +146,48 @@ THEMES = [
'bg2': '#0a1a00',
'border': '#153a00',
'text': '#ddddcc',
'is_light': False,
},
# Light themes
{
'name': 'LIGHT BLUE',
'accent': '#0066cc',
'accent_dim': '#0044aa',
'bg': '#f5f5f5',
'bg2': '#e8e8e8',
'border': '#bbbbbb',
'text': '#222222',
'is_light': True,
},
{
'name': 'LIGHT GREEN',
'accent': '#00aa44',
'accent_dim': '#008833',
'bg': '#f0f5f0',
'bg2': '#e0e8e0',
'border': '#aaccaa',
'text': '#112211',
'is_light': True,
},
{
'name': 'LIGHT AMBER',
'accent': '#cc8800',
'accent_dim': '#aa6600',
'bg': '#f5f3f0',
'bg2': '#e8e5e0',
'border': '#ccbb99',
'text': '#221100',
'is_light': True,
},
{
'name': 'LIGHT PURPLE',
'accent': '#8844cc',
'accent_dim': '#6622aa',
'bg': '#f3f0f5',
'bg2': '#e5e0e8',
'border': '#ccbbdd',
'text': '#221122',
'is_light': True,
},
]
@@ -197,9 +251,6 @@ class TopBarWidget(QFrame):
# Date
self.date_label = QLabel("")
self.date_label.setFont(QFont("Arial", 20, QFont.Bold))
self.date_label.setStyleSheet(
"color: #aaaaaa; background: transparent; border: none;"
)
self.date_label.setAlignment(Qt.AlignCenter | Qt.AlignVCenter)
layout.addWidget(self.date_label)
@@ -210,7 +261,7 @@ class TopBarWidget(QFrame):
self.clock_label.setFont(QFont("Courier New", 42, QFont.Bold))
self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.clock_label.setMinimumWidth(220)
self._style_clock()
self._style_clock_and_date()
layout.addWidget(self.clock_label)
def _style_theme_btn(self):
@@ -224,24 +275,29 @@ class TopBarWidget(QFrame):
}}
QPushButton:hover {{
background-color: {theme['accent']};
color: #000000;
color: {'#ffffff' if not theme['is_light'] else '#000000'};
}}
QPushButton:pressed {{
background-color: {theme['accent_dim']};
}}
""")
def _style_clock(self):
def _style_clock_and_date(self):
theme = THEMES[self._theme_index]
self.clock_label.setStyleSheet(
f"color: {theme['accent']}; background: transparent; border: none;"
)
# Date color adapts to light/dark
date_color = '#555555' if theme['is_light'] else '#aaaaaa'
self.date_label.setStyleSheet(
f"color: {date_color}; background: transparent; border: none;"
)
def _cycle_theme(self):
self._theme_index = (self._theme_index + 1) % len(THEMES)
self._apply_bar_style()
self._style_theme_btn()
self._style_clock()
self._style_clock_and_date()
self.theme_changed.emit(THEMES[self._theme_index])
def _update(self):