diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b5f630 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +venv/ diff --git a/__pycache__/audio_engine.cpython-314.pyc b/__pycache__/audio_engine.cpython-314.pyc deleted file mode 100644 index 64d474e..0000000 Binary files a/__pycache__/audio_engine.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/cart_widget.cpython-314.pyc b/__pycache__/cart_widget.cpython-314.pyc deleted file mode 100644 index 1f5498c..0000000 Binary files a/__pycache__/cart_widget.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/deck_widget.cpython-314.pyc b/__pycache__/deck_widget.cpython-314.pyc deleted file mode 100644 index a5d7a1d..0000000 Binary files a/__pycache__/deck_widget.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/fingerprint_widget.cpython-314.pyc b/__pycache__/fingerprint_widget.cpython-314.pyc deleted file mode 100644 index bc55621..0000000 Binary files a/__pycache__/fingerprint_widget.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/history_widget.cpython-314.pyc b/__pycache__/history_widget.cpython-314.pyc deleted file mode 100644 index 92ac722..0000000 Binary files a/__pycache__/history_widget.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/lufs_meter_widget.cpython-314.pyc b/__pycache__/lufs_meter_widget.cpython-314.pyc deleted file mode 100644 index 692b7a6..0000000 Binary files a/__pycache__/lufs_meter_widget.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/midi_engine.cpython-314.pyc b/__pycache__/midi_engine.cpython-314.pyc deleted file mode 100644 index 88a303e..0000000 Binary files a/__pycache__/midi_engine.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/playlist_widget.cpython-314.pyc b/__pycache__/playlist_widget.cpython-314.pyc deleted file mode 100644 index f97dad9..0000000 Binary files a/__pycache__/playlist_widget.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/top_bar_widget.cpython-314.pyc b/__pycache__/top_bar_widget.cpython-314.pyc deleted file mode 100644 index 25db27a..0000000 Binary files a/__pycache__/top_bar_widget.cpython-314.pyc and /dev/null differ diff --git a/audio_engine.py b/audio_engine.py index 1edc91a..1f32fd4 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -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) diff --git a/cart_widget.py b/cart_widget.py index 48a5222..283f5f7 100644 --- a/cart_widget.py +++ b/cart_widget.py @@ -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) diff --git a/deck_widget.py b/deck_widget.py index e120d81..6bf6cf0 100644 --- a/deck_widget.py +++ b/deck_widget.py @@ -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( diff --git a/fingerprint_widget.py b/fingerprint_widget.py deleted file mode 100644 index 99324fc..0000000 --- a/fingerprint_widget.py +++ /dev/null @@ -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;") \ No newline at end of file diff --git a/history_widget.py b/history_widget.py index 6febf8a..06ff245 100644 --- a/history_widget.py +++ b/history_widget.py @@ -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 - ) \ No newline at end of file + 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]) diff --git a/lufs_meter_widget.py b/lufs_meter_widget.py index e8033df..51f606a 100644 --- a/lufs_meter_widget.py +++ b/lufs_meter_widget.py @@ -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) \ No newline at end of file + # 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() \ No newline at end of file diff --git a/playlist_widget.py b/playlist_widget.py index 780c586..ca71e94 100644 --- a/playlist_widget.py +++ b/playlist_widget.py @@ -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(""" @@ -602,4 +654,51 @@ class PlaylistWidget(QWidget): splitter.addWidget(self.cart_panel) splitter.setSizes([600, 400]) - root.addWidget(splitter) \ No newline at end of file + 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) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cd3368f..c35d656 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PySide6 numpy soundfile -requests +pyloudnorm mutagen musicbrainzngs python-rtmidi PyJACK -librosa \ No newline at end of file +librosa diff --git a/top_bar_widget.py b/top_bar_widget.py index 2e30805..8a2d9e5 100644 --- a/top_bar_widget.py +++ b/top_bar_widget.py @@ -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):