techdj/techdj_qt_v1.py.bak

2801 lines
228 KiB
Python

#!/usr/bin/env python3
"""
TechDJ - PyQt5 Native DJ Application
Pixel-perfect replica of the web DJ panel with neon aesthetic
"""
import sys
import os
import json
import requests
import numpy as np
import sounddevice as sd
import soundfile as sf
from pathlib import Path
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QSlider, QListWidget, QListWidgetItem,
QLineEdit, QFrame, QSplitter, QProgressBar, QMessageBox,
QDialog, QGridLayout, QCheckBox, QComboBox, QFileDialog)
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, QRectF, QPropertyAnimation, QEasingCurve, QProcess, QSize
import re
from PyQt5.QtGui import (QPainter, QColor, QPen, QFont, QLinearGradient,
QRadialGradient, QBrush, QPainterPath, QFontDatabase, QIcon)
import socketio
import queue
import subprocess
import time
import threading
from scipy import signal
# Color constants matching web panel
BG_DARK = QColor(10, 10, 18)
PANEL_BG = QColor(20, 20, 30, 204) # 0.8 alpha
PRIMARY_CYAN = QColor(0, 243, 255)
SECONDARY_MAGENTA = QColor(188, 19, 254)
TEXT_MAIN = QColor(224, 224, 224)
TEXT_DIM = QColor(136, 136, 136)
class AudioEngine:
"""Efficient local audio processing engine"""
def __init__(self):
self.decks = {
'A': {
'audio_data': None,
'sample_rate': 44100,
'position': 0,
'playing': False,
'volume': 0.8,
'speed': 1.0,
'eq': {'low': 0, 'mid': 0, 'high': 0},
'filters': {'lowpass': 100, 'highpass': 0},
'duration': 0,
'filename': None,
'cues': {},
'loop_start': None,
'loop_end': None,
'loop_active': False,
'repeat': False,
'queue': [],
'needs_next_track': False,
},
'B': {
'audio_data': None,
'sample_rate': 44100,
'position': 0,
'playing': False,
'volume': 0.8,
'speed': 1.0,
'eq': {'low': 0, 'mid': 0, 'high': 0},
'filters': {'lowpass': 100, 'highpass': 0},
'duration': 0,
'filename': None,
'cues': {},
'loop_start': None,
'loop_end': None,
'loop_active': False,
'repeat': False,
'queue': [],
'needs_next_track': False,
}
}
self.crossfader = 0.5
self.master_volume = 0.8
self.stream = None
self.running = False
self.broadcast_queue = queue.Queue(maxsize=100)
self.is_broadcasting = False
self.lock = threading.Lock()
# Filter states for each deck [deck_id][filter_name][channel]
self._filter_states = {
'A': {
'low': [np.zeros(2), np.zeros(2)],
'mid': [np.zeros(2), np.zeros(2)],
'high': [np.zeros(2), np.zeros(2)],
'lp': [np.zeros(2), np.zeros(2)],
'hp': [np.zeros(2), np.zeros(2)]
},
'B': {
'low': [np.zeros(2), np.zeros(2)],
'mid': [np.zeros(2), np.zeros(2)],
'high': [np.zeros(2), np.zeros(2)],
'lp': [np.zeros(2), np.zeros(2)],
'hp': [np.zeros(2), np.zeros(2)]
}
}
# Pre-calculated filter coefficients
self._filter_coeffs = {}
self._init_filters()
# Pre-allocate reuse buffers for the audio thread
self._target_indices = np.arange(2048, dtype=np.float32) # Matches blocksize
def _init_filters(self):
"""Pre-calculate coefficients for standard bands"""
sr = 44100
# Use standard pass filters for initialization
self._filter_coeffs['low'] = signal.butter(1, 300 / (sr/2), 'low')
self._filter_coeffs['mid'] = signal.butter(1, [400 / (sr/2), 3500 / (sr/2)], 'bandpass')
self._filter_coeffs['high'] = signal.butter(1, 4000 / (sr/2), 'high')
def _apply_processing(self, deck_id, chunk):
"""Apply EQ and Filters to the audio chunk"""
sr = 44100
deck = self.decks[deck_id]
states = self._filter_states[deck_id]
# 1. Apply EQ (Gain-based)
# We use a simple gain filter approximation for performance
low_gain = 10**(deck['eq']['low'] / 20.0)
mid_gain = 10**(deck['eq']['mid'] / 20.0)
high_gain = 10**(deck['eq']['high'] / 20.0)
if low_gain != 1.0 or mid_gain != 1.0 or high_gain != 1.0:
# Simple gain scaling for demo; real biquads are better but more CPU intensive in Python
# For now, let's use a simple 3-band gain model
# Re-implementing as basic biquads for "Pro" feel
for ch in range(2):
# Low Shelf
b, a = signal.butter(1, 300/(sr/2), 'lowshelf')
# Adjust b for gain: b_gain = [b[0]*G, b[1]]? No, standard biquad gain is better
# But Scipy's butter doesn't take gain. We'll use a simpler approach for now:
# Multiply signal by gain factors for the specific bands.
pass
# Simplified "Musical" EQ:
# We'll just apply the filters and sum them with gains
# This is more robust than chaining biquads for a high-level API
pass
# Since proper IIR chaining is complex in a Python loop, we'll implement
# a high-performance resonance filter for LP/HP which is the most audible
try:
# Low Pass Filter
lp_val = deck['filters']['lowpass'] # 0-100
if lp_val < 100:
freq = max(50, 20000 * (lp_val / 100.0)**2)
b, a = signal.butter(1, freq / (sr/2), 'low')
for ch in range(2):
chunk[:, ch], states['lp'][ch] = signal.lfilter(b, a, chunk[:, ch], zi=states['lp'][ch])
# High Pass Filter
hp_val = deck['filters']['highpass'] # 0-100
if hp_val > 0:
freq = max(20, 15000 * (hp_val / 100.0)**2)
b, a = signal.butter(1, freq / (sr/2), 'high')
for ch in range(2):
chunk[:, ch], states['hp'][ch] = signal.lfilter(b, a, chunk[:, ch], zi=states['hp'][ch])
except Exception as e:
# Fallback if filter design fails due to extreme values
print(f"Filter processing error: {e}")
pass
# EQ Gain (Simple multiplier for now to ensure sliders "do something")
combined_eq_gain = (low_gain + mid_gain + high_gain) / 3.0
return chunk * combined_eq_gain
def start_stream(self):
if self.stream is not None:
return
self.running = True
self.stream = sd.OutputStream(
channels=2,
samplerate=44100,
blocksize=2048,
callback=self._audio_callback
)
self.stream.start()
print("🎵 Audio stream started")
def stop_stream(self):
self.running = False
if self.stream:
self.stream.stop()
self.stream.close()
self.stream = None
def _audio_callback(self, outdata, frames, time_info, status):
output = np.zeros((frames, 2), dtype=np.float32)
output_samplerate = 44100
with self.lock:
for deck_id in ['A', 'B']:
deck = self.decks[deck_id]
if not deck['playing'] or deck['audio_data'] is None:
continue
# Calculate source indices via linear interpolation
rate_ratio = deck['sample_rate'] / output_samplerate
step = rate_ratio * deck['speed']
# Start and end in source domain
src_start = deck['position']
num_src_samples_needed = frames * step
src_end = src_start + num_src_samples_needed
# Bounds check
if src_start >= len(deck['audio_data']) - 1:
deck['playing'] = False
continue
# Prepare source data
# Ensure we don't read past the end
read_end = int(np.ceil(src_end)) + 1
if read_end > len(deck['audio_data']):
read_end = len(deck['audio_data'])
src_chunk = deck['audio_data'][int(src_start):read_end]
if len(src_chunk) < 2:
deck['playing'] = False
continue
if src_chunk.ndim == 1:
src_chunk = np.column_stack((src_chunk, src_chunk))
# Time indices for interpolation
if len(self._target_indices) != frames:
self._target_indices = np.arange(frames, dtype=np.float32)
x_target = self._target_indices * step
x_source = np.arange(len(src_chunk))
# Interp each channel
try:
resampled_l = np.interp(x_target, x_source, src_chunk[:, 0])
resampled_r = np.interp(x_target, x_source, src_chunk[:, 1])
chunk = np.column_stack((resampled_l, resampled_r))
# Apply processing (EQ and Filters)
chunk = self._apply_processing(deck_id, chunk)
chunk = chunk * deck['volume']
if deck_id == 'A':
chunk = chunk * (1.0 - self.crossfader)
else:
chunk = chunk * self.crossfader
output += chunk
# Update position
deck['position'] += num_src_samples_needed
except Exception as e:
print(f"Audio thread error in interp: {e}")
deck['playing'] = False
continue
# Handle looping
if deck['loop_active'] and deck['loop_start'] is not None and deck['loop_end'] is not None:
loop_start_frame = deck['loop_start'] * deck['sample_rate']
loop_end_frame = deck['loop_end'] * deck['sample_rate']
if deck['position'] >= loop_end_frame:
deck['position'] = loop_start_frame + (deck['position'] - loop_end_frame)
# Auto-stop at end
if deck['position'] >= len(deck['audio_data']):
if deck['repeat']:
# Loop current track
deck['position'] = 0
elif len(deck['queue']) > 0:
# Mark that we need to load next track
# Can't load here (wrong thread), UI will handle it
deck['playing'] = False
deck['needs_next_track'] = True
else:
deck['playing'] = False
output = output * self.master_volume
outdata[:] = output
# Capture for broadcast
if self.is_broadcasting:
try:
self.broadcast_queue.put_nowait(output.tobytes())
except queue.Full:
pass
def load_track(self, deck_id, filepath):
try:
audio_data, sample_rate = sf.read(filepath, dtype='float32')
with self.lock:
self.decks[deck_id]['audio_data'] = audio_data
self.decks[deck_id]['sample_rate'] = sample_rate
self.decks[deck_id]['position'] = 0
self.decks[deck_id]['duration'] = len(audio_data) / sample_rate
self.decks[deck_id]['filename'] = os.path.basename(filepath)
print(f"✅ Loaded {os.path.basename(filepath)} to Deck {deck_id}")
return True
except Exception as e:
print(f"❌ Error loading {filepath}: {e}")
return False
def play(self, deck_id):
with self.lock:
if self.decks[deck_id]['audio_data'] is not None:
self.decks[deck_id]['playing'] = True
def pause(self, deck_id):
with self.lock:
self.decks[deck_id]['playing'] = False
def seek(self, deck_id, position_seconds):
with self.lock:
deck = self.decks[deck_id]
if deck['audio_data'] is not None:
deck['position'] = int(position_seconds * deck['sample_rate'])
def set_volume(self, deck_id, volume):
with self.lock:
self.decks[deck_id]['volume'] = max(0.0, min(1.0, volume))
def set_speed(self, deck_id, speed):
with self.lock:
self.decks[deck_id]['speed'] = max(0.5, min(1.5, speed))
def set_crossfader(self, value):
with self.lock:
self.crossfader = max(0.0, min(1.0, value))
def get_position(self, deck_id):
with self.lock:
deck = self.decks[deck_id]
if deck['audio_data'] is not None:
return deck['position'] / deck['sample_rate']
return 0.0
def set_cue(self, deck_id, cue_num):
position = self.get_position(deck_id)
with self.lock:
self.decks[deck_id]['cues'][cue_num] = position
def jump_to_cue(self, deck_id, cue_num):
with self.lock:
if cue_num in self.decks[deck_id]['cues']:
position = self.decks[deck_id]['cues'][cue_num]
self.seek(deck_id, position)
def set_eq(self, deck_id, band, value):
with self.lock:
self.decks[deck_id]['eq'][band] = value
def set_filter(self, deck_id, filter_type, value):
with self.lock:
self.decks[deck_id]['filters'][filter_type] = value
def set_repeat(self, deck_id, enabled):
"""Toggle repeat/loop for a deck"""
with self.lock:
self.decks[deck_id]['repeat'] = enabled
def set_loop_in(self, deck_id):
position = self.get_position(deck_id)
with self.lock:
self.decks[deck_id]['loop_start'] = position
# If we already have an end, activate loop
if self.decks[deck_id]['loop_end'] is not None:
self.decks[deck_id]['loop_active'] = True
def set_loop_out(self, deck_id):
position = self.get_position(deck_id)
with self.lock:
self.decks[deck_id]['loop_end'] = position
# If we already have a start, activate loop
if self.decks[deck_id]['loop_start'] is not None:
self.decks[deck_id]['loop_active'] = True
def exit_loop(self, deck_id):
with self.lock:
self.decks[deck_id]['loop_active'] = False
self.decks[deck_id]['loop_start'] = None
self.decks[deck_id]['loop_end'] = None
def add_to_queue(self, deck_id, filepath):
"""Add track to deck's queue"""
with self.lock:
self.decks[deck_id]['queue'].append(filepath)
def remove_from_queue(self, deck_id, index):
"""Remove track from queue by index"""
with self.lock:
if 0 <= index < len(self.decks[deck_id]['queue']):
self.decks[deck_id]['queue'].pop(index)
def clear_queue(self, deck_id):
"""Clear all tracks from queue"""
with self.lock:
self.decks[deck_id]['queue'].clear()
def get_queue(self, deck_id):
"""Get current queue (returns a copy)"""
with self.lock:
return list(self.decks[deck_id]['queue'])
def pop_next_from_queue(self, deck_id):
"""Get and remove next track from queue"""
with self.lock:
if len(self.decks[deck_id]['queue']) > 0:
return self.decks[deck_id]['queue'].pop(0)
return None
class DownloadThread(QThread):
progress = pyqtSignal(int)
finished = pyqtSignal(str, bool)
def __init__(self, url, filepath):
super().__init__()
self.url = url
self.filepath = filepath
def run(self):
try:
print(f"📥 Downloading from: {self.url}")
response = requests.get(self.url, stream=True, timeout=30)
# Check if request was successful
if response.status_code != 200:
print(f"❌ HTTP {response.status_code}: {self.url}")
self.finished.emit(self.filepath, False)
return
total_size = int(response.headers.get('content-length', 0))
print(f"📦 File size: {total_size / 1024 / 1024:.2f} MB")
os.makedirs(os.path.dirname(self.filepath), exist_ok=True)
downloaded = 0
with open(self.filepath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
progress = int((downloaded / total_size) * 100)
self.progress.emit(progress)
print(f"✅ Download complete: {os.path.basename(self.filepath)}")
self.finished.emit(self.filepath, True)
except requests.exceptions.Timeout:
print(f"❌ Download timeout: {self.url}")
self.finished.emit(self.filepath, False)
except requests.exceptions.ConnectionError as e:
print(f"❌ Connection error: {e}")
self.finished.emit(self.filepath, False)
except Exception as e:
print(f"❌ Download error: {type(e).__name__}: {e}")
self.finished.emit(self.filepath, False)
class BroadcastThread(QThread):
"""Thread to handle FFmpeg encoding and streaming"""
chunk_ready = pyqtSignal(bytes)
error = pyqtSignal(str)
def __init__(self, audio_queue, bitrate="192k"):
super().__init__()
self.audio_queue = audio_queue
self.bitrate = bitrate
self.running = False
self.process = None
def run(self):
self.running = True
# FFmpeg command to read raw f32le PCM and output MP3 chunks to stdout
# Using CBR and zerolatency tune for stability
cmd = [
'ffmpeg',
'-y',
'-fflags', 'nobuffer',
'-flags', 'low_delay',
'-probesize', '32',
'-analyzeduration', '0',
'-f', 'f32le',
'-ar', '44100',
'-ac', '2',
'-i', 'pipe:0',
'-codec:a', 'libmp3lame',
'-b:a', self.bitrate,
'-maxrate', self.bitrate,
'-minrate', self.bitrate,
'-bufsize', '64k',
'-tune', 'zerolatency',
'-flush_packets', '1',
'-f', 'mp3',
'pipe:1'
]
try:
self.process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0
)
# Thread to read encoded chunks from stdout
def read_output():
# Smaller buffer for more frequent updates (2KB = ~0.08s @ 192k)
buffer_size = 2048
while self.running:
try:
data = self.process.stdout.read(buffer_size)
if data:
self.chunk_ready.emit(data)
else:
break
except Exception as e:
print(f"Broadcast output error: {e}")
break
output_thread = threading.Thread(target=read_output, daemon=True)
output_thread.start()
print(f"📡 FFmpeg broadcast process started ({self.bitrate})")
# Worker to feed stdin from the broadcast queue
while self.running:
try:
# Clear queue if it's way too full, but be less aggressive
# 100 chunks is ~4.6 seconds. If we hit 200, we're definitely lagging.
if self.audio_queue.qsize() > 200:
while self.audio_queue.qsize() > 50:
self.audio_queue.get_nowait()
chunk = self.audio_queue.get(timeout=0.1)
if chunk and self.process and self.process.stdin:
self.process.stdin.write(chunk)
self.process.stdin.flush()
except queue.Empty:
continue
except Exception as e:
print(f"Broadcast input error: {e}")
break
except Exception as e:
self.error.emit(str(e))
self.running = False
return
def stop(self):
self.running = False
if self.process:
self.process.terminate()
try:
self.process.wait(timeout=2)
except:
self.process.kill()
self.process = None
# Give output thread time to finish
time.sleep(0.1)
print("🛑 Broadcast process stopped")
class WaveformWidget(QWidget):
"""Waveform display matching web panel style"""
def __init__(self, deck_id, parent=None):
super().__init__(parent)
self.deck_id = deck_id
self.waveform_data = []
self.position = 0.0
self.duration = 1.0
self.cues = {}
self.setFixedHeight(180) # Pro-visual height
self.setStyleSheet("background: #000; border: none;") # Removed internal border
def set_waveform(self, audio_data, sample_rate):
if audio_data is None:
self.waveform_data = []
return
samples = 2000 # Increased resolution
if audio_data.ndim > 1:
audio_data = np.mean(audio_data, axis=1)
# Normalize globally for better visualization
max_val = np.max(np.abs(audio_data))
if max_val > 0:
audio_data = audio_data / max_val
block_size = max(1, len(audio_data) // samples)
self.waveform_data = []
for i in range(samples):
start = i * block_size
end = min(start + block_size, len(audio_data))
if start < len(audio_data):
chunk = audio_data[start:end]
# Store both max and min for a more detailed mirror wave
self.waveform_data.append((np.max(chunk), np.min(chunk)))
self.update()
def set_position(self, position, duration):
# Only update if position changed significantly (reduces repaints)
if abs(position - self.position) > 0.1 or duration != self.duration:
self.position = position
self.duration = max(duration, 0.01)
self.update()
def set_cues(self, cues):
self.cues = cues
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# Background
painter.fillRect(self.rect(), QColor(0, 0, 0))
if not self.waveform_data:
return
# Draw waveform
width = self.width()
height = self.height()
bar_width = width / len(self.waveform_data)
wave_color = PRIMARY_CYAN if self.deck_id == 'A' else SECONDARY_MAGENTA
painter.setPen(Qt.NoPen)
# Create semi-transparent brush for visual depth
brush_color = QColor(wave_color)
brush_color.setAlpha(180)
painter.setBrush(QBrush(brush_color))
for i, (peak, val) in enumerate(self.waveform_data):
x = i * bar_width
# Use almost full height (0.95) to make it look "tall" as requested
# 'peak' and 'val' are normalized -1 to 1
pos_height = peak * (height / 2) * 0.95
neg_height = abs(val) * (height / 2) * 0.95
# Top half
painter.drawRect(int(x), int(height/2 - pos_height), max(1, int(bar_width)), int(pos_height))
# Bottom half
painter.drawRect(int(x), int(height/2), max(1, int(bar_width)), int(neg_height))
# Draw cue markers
if self.duration > 0:
painter.setPen(QPen(QColor(255, 255, 255), 1))
for cue_time in self.cues.values():
x = (cue_time / self.duration) * width
painter.drawLine(int(x), 0, int(x), height)
# Draw playhead
if self.duration > 0:
playhead_x = (self.position / self.duration) * width
painter.setPen(QPen(QColor(255, 255, 0), 2))
painter.drawLine(int(playhead_x), 0, int(playhead_x), height)
def mousePressEvent(self, event):
"""Allow seeking by clicking on waveform"""
if self.duration > 0:
percent = event.x() / self.width()
seek_time = percent * self.duration
self.parent().parent().seek_deck(seek_time)
class VinylDiskWidget(QWidget):
"""Animated vinyl disk matching web panel"""
clicked = pyqtSignal()
def __init__(self, deck_id, parent=None):
super().__init__(parent)
self.deck_id = deck_id
self.rotation = 0
self.playing = False
self.setFixedSize(120, 120)
# Rotation animation
self.timer = QTimer()
self.timer.timeout.connect(self.rotate)
def set_playing(self, playing):
self.playing = playing
if playing:
self.timer.start(100) # 10 FPS - reduced for better performance
else:
self.timer.stop()
self.update()
def set_speed(self, speed):
self.speed = speed
def rotate(self):
# Base rotation is 5 degrees, scaled by playback speed
speed_factor = getattr(self, 'speed', 1.0)
self.rotation = (self.rotation + (5 * speed_factor)) % 360
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
center_x = self.width() / 2
center_y = self.height() / 2
radius = min(center_x, center_y) - 5
# Rotate if playing
if self.playing:
painter.translate(center_x, center_y)
painter.rotate(self.rotation)
painter.translate(-center_x, -center_y)
# Vinyl gradient
gradient = QRadialGradient(center_x, center_y, radius)
gradient.setColorAt(0, QColor(34, 34, 34))
gradient.setColorAt(0.1, QColor(17, 17, 17))
gradient.setColorAt(1, QColor(0, 0, 0))
painter.setBrush(gradient)
painter.setPen(QPen(QColor(51, 51, 51), 2))
painter.drawEllipse(int(center_x - radius), int(center_y - radius),
int(radius * 2), int(radius * 2))
# Grooves
painter.setPen(QPen(QColor(24, 24, 24), 1))
for i in range(5, int(radius), 8):
painter.drawEllipse(int(center_x - i), int(center_y - i), i * 2, i * 2)
# Center label
label_radius = 25
label_color = PRIMARY_CYAN if self.deck_id == 'A' else SECONDARY_MAGENTA
painter.setBrush(label_color)
painter.setPen(QPen(label_color.darker(120), 2))
painter.drawEllipse(int(center_x - label_radius), int(center_y - label_radius),
label_radius * 2, label_radius * 2)
# Label text
painter.setPen(QColor(0, 0, 0))
font = QFont("Orbitron", 16, QFont.Bold)
painter.setFont(font)
painter.drawText(self.rect(), Qt.AlignCenter, self.deck_id)
# Glow effect when playing
if self.playing:
painter.setPen(QPen(label_color, 3))
painter.setBrush(Qt.NoBrush)
painter.drawEllipse(int(center_x - radius - 3), int(center_y - radius - 3),
int((radius + 3) * 2), int((radius + 3) * 2))
def mousePressEvent(self, event):
self.clicked.emit()
class NeonButton(QPushButton):
"""Neon-styled button matching web panel"""
def __init__(self, text, color=PRIMARY_CYAN, parent=None):
super().__init__(text, parent)
self.neon_color = color
self.is_active = False
self.update_style()
def set_active(self, active):
self.is_active = active
self.update_style()
def update_style(self):
if self.is_active:
self.setStyleSheet(f"""
QPushButton {{
background: rgba({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}, 0.3);
border: 2px solid rgb({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()});
color: rgb({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()});
font-family: 'Orbitron';
font-weight: bold;
padding: 8px;
border-radius: 4px;
}}
QPushButton:hover {{
background: rgba({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}, 0.5);
}}
""")
else:
self.setStyleSheet(f"""
QPushButton {{
background: #222;
border: 1px solid #444;
color: #666;
font-family: 'Orbitron';
font-weight: bold;
padding: 8px;
border-radius: 4px;
}}
QPushButton:hover {{
background: #333;
color: #888;
}}
""")
class DeckWidget(QWidget):
"""Complete deck widget matching web panel layout"""
def __init__(self, deck_id, audio_engine, parent=None):
super().__init__(parent)
self.deck_id = deck_id
self.audio_engine = audio_engine
self.color = PRIMARY_CYAN if deck_id == 'A' else SECONDARY_MAGENTA
self.init_ui()
# Update timer
self.timer = QTimer()
self.timer.timeout.connect(self.update_display)
self.timer.start(100) # 10 FPS - reduced for better performance
def init_ui(self):
layout = QVBoxLayout()
layout.setSpacing(5) # Reduced from 8
layout.setContentsMargins(10, 8, 10, 10) # Reduced top margin
# Headers removed as requested
# Waveform
waveform_container = QWidget()
waveform_container.setFixedHeight(184) # 180px graph + 4px padding
waveform_container.setStyleSheet("background: #111; border: 1px solid #333; border-radius: 4px;")
waveform_layout = QVBoxLayout(waveform_container)
waveform_layout.setContentsMargins(2, 2, 2, 2)
self.waveform = WaveformWidget(self.deck_id, self)
waveform_layout.addWidget(self.waveform)
# Subtle Metadata Overlay (Integrated into Graph Box)
meta_layout = QHBoxLayout()
meta_layout.setContentsMargins(4, 0, 4, 1)
self.deck_id_label = QLabel(f"[{self.deck_id}]")
self.deck_id_label.setStyleSheet(f"color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); font-family: 'Orbitron'; font-size: 9px; font-weight: bold;")
meta_layout.addWidget(self.deck_id_label)
self.track_label = QLabel("EMPTY")
self.track_label.setStyleSheet("color: #bbb; font-family: 'Rajdhani'; font-size: 9px; font-weight: bold;")
meta_layout.addWidget(self.track_label, 1)
self.time_label = QLabel("0:00 / 0:00")
self.time_label.setStyleSheet("color: #888; font-family: 'Orbitron'; font-size: 8px;")
meta_layout.addWidget(self.time_label)
waveform_layout.addLayout(meta_layout)
layout.addWidget(waveform_container)
# Restoring the nice DJ circles
disk_container = QHBoxLayout()
disk_container.addStretch()
self.vinyl_disk = VinylDiskWidget(self.deck_id)
self.vinyl_disk.clicked.connect(self.toggle_play)
disk_container.addWidget(self.vinyl_disk)
disk_container.addStretch()
layout.addLayout(disk_container)
# Hot Cues
cue_layout = QGridLayout()
cue_layout.setSpacing(3)
self.cue_buttons = []
for i in range(4):
btn = NeonButton(f"CUE {i+1}", self.color)
btn.clicked.connect(lambda checked, num=i+1: self.handle_cue(num))
cue_layout.addWidget(btn, 0, i)
self.cue_buttons.append(btn)
layout.addLayout(cue_layout)
# Loop Controls
loop_layout = QGridLayout()
loop_layout.setSpacing(3)
loop_in = NeonButton("LOOP IN", QColor(255, 102, 0))
loop_in.clicked.connect(lambda: self.audio_engine.set_loop_in(self.deck_id))
loop_out = NeonButton("LOOP OUT", QColor(255, 102, 0))
loop_out.clicked.connect(lambda: self.audio_engine.set_loop_out(self.deck_id))
loop_exit = NeonButton("EXIT", QColor(255, 102, 0))
loop_exit.clicked.connect(lambda: self.audio_engine.exit_loop(self.deck_id))
loop_layout.addWidget(loop_in, 0, 0)
loop_layout.addWidget(loop_out, 0, 1)
loop_layout.addWidget(loop_exit, 0, 2)
layout.addLayout(loop_layout)
# Controls Grid
controls = QGridLayout()
controls.setSpacing(8)
# Volume
vol_label = QLabel("VOLUME")
vol_label.setStyleSheet("color: #888; font-size: 10px;")
controls.addWidget(vol_label, 0, 0)
self.volume_slider = QSlider(Qt.Horizontal)
self.volume_slider.setRange(0, 100)
self.volume_slider.setValue(80)
self.volume_slider.valueChanged.connect(self.on_volume_change)
self.volume_slider.setStyleSheet(self.get_slider_style())
controls.addWidget(self.volume_slider, 1, 0)
# EQ
eq_widget = QWidget()
eq_layout = QHBoxLayout(eq_widget)
eq_layout.setSpacing(8)
self.eq_sliders = {}
for band in ['HIGH', 'MID', 'LOW']:
band_widget = QWidget()
band_layout = QVBoxLayout(band_widget)
band_layout.setSpacing(2)
band_layout.setContentsMargins(0, 0, 0, 0)
slider = QSlider(Qt.Vertical)
slider.setRange(-20, 20)
slider.setValue(0)
slider.setFixedHeight(80)
slider.setStyleSheet(self.get_slider_style())
slider.valueChanged.connect(lambda v, b=band.lower(): self.on_eq_change(b, v))
self.eq_sliders[band.lower()] = slider
label = QLabel(band)
label.setStyleSheet("color: #888; font-size: 9px;")
label.setAlignment(Qt.AlignCenter)
band_layout.addWidget(slider)
band_layout.addWidget(label)
eq_layout.addWidget(band_widget)
controls.addWidget(eq_widget, 0, 1, 2, 1)
# Filters
filter_widget = QWidget()
filter_layout = QVBoxLayout(filter_widget)
filter_layout.setSpacing(4)
lp_label = QLabel("LOW-PASS")
lp_label.setStyleSheet("color: #888; font-size: 9px;")
filter_layout.addWidget(lp_label)
self.lp_slider = QSlider(Qt.Horizontal)
self.lp_slider.setRange(0, 100)
self.lp_slider.setValue(100)
self.lp_slider.setStyleSheet(self.get_slider_style())
self.lp_slider.valueChanged.connect(lambda v: self.audio_engine.set_filter(self.deck_id, 'lowpass', v))
filter_layout.addWidget(self.lp_slider)
hp_label = QLabel("HIGH-PASS")
hp_label.setStyleSheet("color: #888; font-size: 9px;")
filter_layout.addWidget(hp_label)
self.hp_slider = QSlider(Qt.Horizontal)
self.hp_slider.setRange(0, 100)
self.hp_slider.setValue(0)
self.hp_slider.setStyleSheet(self.get_slider_style())
self.hp_slider.valueChanged.connect(lambda v: self.audio_engine.set_filter(self.deck_id, 'highpass', v))
filter_layout.addWidget(self.hp_slider)
controls.addWidget(filter_widget, 0, 2, 2, 1)
# Speed
speed_widget = QWidget()
speed_layout = QVBoxLayout(speed_widget)
speed_layout.setSpacing(4)
speed_label = QLabel("PITCH / TEMPO")
speed_label.setStyleSheet("color: #888; font-size: 9px;")
speed_layout.addWidget(speed_label)
self.speed_slider = QSlider(Qt.Horizontal)
self.speed_slider.setRange(50, 150)
self.speed_slider.setValue(100)
self.speed_slider.valueChanged.connect(self.on_speed_change)
self.speed_slider.setStyleSheet(self.get_slider_style())
speed_layout.addWidget(self.speed_slider)
bend_layout = QHBoxLayout()
bend_minus = QPushButton("-")
bend_minus.setFixedSize(30, 25)
bend_minus.pressed.connect(lambda: self.on_pitch_bend(-0.02))
bend_minus.released.connect(lambda: self.on_pitch_bend(0))
bend_plus = QPushButton("+")
bend_plus.setFixedSize(30, 25)
bend_plus.pressed.connect(lambda: self.on_pitch_bend(0.02))
bend_plus.released.connect(lambda: self.on_pitch_bend(0))
bend_layout.addWidget(bend_minus)
bend_layout.addWidget(bend_plus)
speed_layout.addLayout(bend_layout)
controls.addWidget(speed_widget, 0, 3, 2, 1)
layout.addLayout(controls)
# Transport
transport = QHBoxLayout()
transport.setSpacing(4)
self.play_btn = NeonButton("▶ PLAY", self.color)
self.play_btn.clicked.connect(self.play)
transport.addWidget(self.play_btn)
self.pause_btn = NeonButton("⏸ PAUSE")
self.pause_btn.clicked.connect(self.pause)
transport.addWidget(self.pause_btn)
sync_btn = NeonButton("SYNC", self.color)
sync_btn.clicked.connect(self.on_sync)
transport.addWidget(sync_btn)
reset_btn = NeonButton("🔄 RESET")
reset_btn.clicked.connect(self.reset_deck)
transport.addWidget(reset_btn)
self.loop_btn = NeonButton("🔁 LOOP")
self.loop_btn.setCheckable(True)
self.loop_btn.clicked.connect(self.toggle_loop)
transport.addWidget(self.loop_btn)
layout.addLayout(transport)
# Queue List
queue_container = QWidget()
queue_container.setStyleSheet("background: rgba(0, 0, 0, 0.4); border-top: 1px solid #333;")
queue_layout = QVBoxLayout(queue_container)
queue_layout.setContentsMargins(5, 5, 5, 5)
queue_layout.setSpacing(2)
queue_label = QLabel("NEXT UP / QUEUE")
queue_label.setStyleSheet(f"color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); font-family: 'Orbitron'; font-size: 9px; font-weight: bold;")
queue_layout.addWidget(queue_label)
self.queue_list = QListWidget()
self.queue_list.setFixedHeight(80)
self.queue_list.setStyleSheet("""
QListWidget {
background: transparent;
border: none;
color: #aaa;
font-family: 'Rajdhani';
font-size: 10px;
}
QListWidget::item {
padding: 2px;
border-bottom: 1px solid #222;
}
""")
queue_layout.addWidget(self.queue_list)
layout.addWidget(queue_container)
layout.addStretch() # Push everything up
self.setLayout(layout)
# Deck styling
self.setStyleSheet(f"""
QWidget {{
background: rgba(20, 20, 30, 0.8);
color: #e0e0e0;
font-family: 'Rajdhani';
}}
QWidget#deck {{
border: 2px solid rgb({self.color.red()}, {self.color.green()}, {self.color.blue()});
border-radius: 8px;
}}
""")
self.setObjectName("deck")
def get_slider_style(self):
return """
QSlider::groove:horizontal {
height: 8px;
background: #333;
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #ccc;
border: 2px solid #888;
width: 16px;
margin: -4px 0;
border-radius: 8px;
}
QSlider::groove:vertical {
width: 8px;
background: #333;
border-radius: 4px;
}
QSlider::handle:vertical {
background: #ccc;
border: 2px solid #888;
height: 16px;
margin: 0 -4px;
border-radius: 8px;
}
"""
def load_track(self, filepath):
if self.audio_engine.load_track(self.deck_id, filepath):
filename = os.path.basename(filepath)
self.track_label.setText(filename.upper())
deck = self.audio_engine.decks[self.deck_id]
self.waveform.set_waveform(deck['audio_data'], deck['sample_rate'])
def play(self):
self.audio_engine.play(self.deck_id)
self.vinyl_disk.set_playing(True)
self.play_btn.set_active(True)
def pause(self):
self.audio_engine.pause(self.deck_id)
self.vinyl_disk.set_playing(False)
self.play_btn.set_active(False)
def toggle_play(self):
if self.audio_engine.decks[self.deck_id]['playing']:
self.pause()
else:
self.play()
def on_volume_change(self, value):
self.audio_engine.set_volume(self.deck_id, value / 100.0)
def on_speed_change(self, value):
self.audio_engine.set_speed(self.deck_id, value / 100.0)
def on_eq_change(self, band, value):
self.audio_engine.set_eq(self.deck_id, band, value)
def on_sync(self):
"""Match speed to other deck"""
other_deck_id = 'B' if self.deck_id == 'A' else 'A'
other_speed = self.audio_engine.decks[other_deck_id]['speed']
self.speed_slider.setValue(int(other_speed * 100))
print(f"🎵 Deck {self.deck_id} synced to {other_speed:.2f}x")
def on_pitch_bend(self, amount):
"""Temporarily adjust speed for nudging"""
base_speed = self.speed_slider.value() / 100.0
self.audio_engine.set_speed(self.deck_id, base_speed + amount)
def handle_cue(self, cue_num):
deck = self.audio_engine.decks[self.deck_id]
if cue_num in deck['cues']:
self.audio_engine.jump_to_cue(self.deck_id, cue_num)
else:
self.audio_engine.set_cue(self.deck_id, cue_num)
self.cue_buttons[cue_num-1].set_active(True)
def seek_deck(self, time):
self.audio_engine.seek(self.deck_id, time)
def reset_deck(self):
"""Reset all deck controls to default values"""
# Setting values on sliders will trigger the valueChanged signal
# which will in turn update the audio engine.
# Reset volume to 80%
self.volume_slider.setValue(80)
# Reset speed to 100%
self.speed_slider.setValue(100)
# Reset EQ sliders to 0
if hasattr(self, 'eq_sliders'):
for band, slider in self.eq_sliders.items():
slider.setValue(0)
# Reset filter sliders
self.lp_slider.setValue(100)
self.hp_slider.setValue(0)
print(f"🔄 Deck {self.deck_id} reset to defaults")
def toggle_loop(self):
"""Toggle loop/repeat for this deck"""
is_looping = self.loop_btn.isChecked()
self.audio_engine.set_repeat(self.deck_id, is_looping)
if is_looping:
self.loop_btn.setStyleSheet(f"""
QPushButton {{
background: rgba({self.color.red()}, {self.color.green()}, {self.color.blue()}, 0.3);
border: 2px solid rgb({self.color.red()}, {self.color.green()}, {self.color.blue()});
color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()});
font-family: 'Orbitron';
font-size: 12px;
font-weight: bold;
border-radius: 6px;
}}
""")
print(f"🔁 Deck {self.deck_id} loop enabled")
else:
self.loop_btn.setStyleSheet("""
QPushButton {
background: rgba(0, 0, 0, 0.3);
border: 2px solid #666;
color: #888;
font-family: 'Orbitron';
font-size: 12px;
border-radius: 6px;
}
""")
print(f"⏹️ Deck {self.deck_id} loop disabled")
def update_display(self):
deck = self.audio_engine.decks[self.deck_id]
position = self.audio_engine.get_position(self.deck_id)
duration = deck['duration']
# Check if we need to load next track from queue
if deck.get('needs_next_track', False):
deck['needs_next_track'] = False
next_track = self.audio_engine.pop_next_from_queue(self.deck_id)
if next_track:
print(f"📋 Auto-loading next track from queue: {os.path.basename(next_track)}")
self.load_track(next_track)
self.play()
# Time calculations
pos_min = int(position // 60)
pos_sec = int(position % 60)
dur_min = int(duration // 60)
dur_sec = int(duration % 60)
self.time_label.setText(f"{pos_min}:{pos_sec:02d} / {dur_min}:{dur_sec:02d}")
self.waveform.set_position(position, duration)
self.waveform.set_cues(deck['cues'])
self.vinyl_disk.set_speed(deck['speed'])
# Update Queue Display (only when changed)
current_queue = deck.get('queue', [])
# Check if queue actually changed (not just count)
queue_changed = False
if self.queue_list.count() != len(current_queue):
queue_changed = True
else:
# Check if items are different
for i, track_path in enumerate(current_queue):
if i >= self.queue_list.count() or self.queue_list.item(i).text() != os.path.basename(track_path):
queue_changed = True
break
if queue_changed:
self.queue_list.clear()
for track_path in current_queue:
filename = os.path.basename(track_path)
self.queue_list.addItem(filename)
class YouTubeSearchDialog(QDialog):
"""Dialog to display and select YouTube search results"""
item_selected = pyqtSignal(str) # Emits the URL
def __init__(self, results, parent=None):
super().__init__(parent)
self.setWindowTitle("YouTube Search Results")
self.setFixedWidth(600)
self.setFixedHeight(400)
self.setStyleSheet(f"""
QDialog {{
background: rgb({BG_DARK.red()}, {BG_DARK.green()}, {BG_DARK.blue()});
border: 2px solid #444;
}}
QLabel {{ color: white; font-family: 'Rajdhani'; }}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(15, 15, 15, 15)
header = QLabel("SELECT A VERSION TO DOWNLOAD")
header.setStyleSheet("font-family: 'Orbitron'; font-weight: bold; font-size: 14px; color: #00f3ff; margin-bottom: 10px;")
layout.addWidget(header)
self.list_widget = QListWidget()
self.list_widget.setStyleSheet("""
QListWidget {
background: rgba(0, 0, 0, 0.4);
border: 1px solid #333;
border-radius: 4px;
color: #ddd;
padding: 5px;
}
QListWidget::item {
border-bottom: 1px solid #222;
padding: 8px;
}
QListWidget::item:hover {
background: rgba(0, 243, 255, 0.1);
}
""")
layout.addWidget(self.list_widget)
for res in results:
# Title ||| Duration ||| URL
parts = res.split(" ||| ")
if len(parts) < 3: continue
title, duration, url = parts[0], parts[1], parts[2]
item = QListWidgetItem(self.list_widget)
item.setSizeHint(QSize(0, 50))
widget = QWidget()
item_layout = QHBoxLayout(widget)
item_layout.setContentsMargins(5, 0, 5, 0)
info_vbox = QVBoxLayout()
info_vbox.setSpacing(0)
title_label = QLabel(title)
title_label.setStyleSheet("font-weight: bold; font-size: 12px; color: #eee;")
title_label.setWordWrap(True)
info_vbox.addWidget(title_label)
dur_label = QLabel(f"Duration: {duration}")
dur_label.setStyleSheet("font-size: 10px; color: #888;")
info_vbox.addWidget(dur_label)
item_layout.addLayout(info_vbox, 1)
dl_btn = NeonButton("DOWNLOAD", PRIMARY_CYAN)
dl_btn.setFixedSize(90, 26)
dl_btn.clicked.connect(lambda _, u=url: self.on_dl_click(u))
item_layout.addWidget(dl_btn)
self.list_widget.setItemWidget(item, widget)
def on_dl_click(self, url):
self.item_selected.emit(url)
self.accept()
class TechDJMainWindow(QMainWindow):
"""Main window matching web panel layout"""
def __init__(self):
super().__init__()
self.server_url = "http://54.37.246.24:5000"
self.cache_dir = Path.home() / ".techdj_cache"
self.cache_dir.mkdir(exist_ok=True)
self.audio_engine = AudioEngine()
self.library = []
self.download_threads = {}
self.broadcasting = False
self.broadcast_thread = None
self.listener_count = 0
self.glow_enabled = {'A': False, 'B': False}
self.glow_intensity = 30
self.deck_loading_target = {'A': None, 'B': None}
# Socket.IO for broadcasting
self.socket = None
# Library settings
self.library_mode = 'server' # 'server' or 'local'
self.server_library = []
self.local_library = []
self.local_folder = None
self.load_settings()
# Search debounce timer
self.search_timer = QTimer()
self.search_timer.setSingleShot(True)
# Search is now fast enough to update quickly
self.search_timer.timeout.connect(lambda: self.update_library_list(rebuild=False))
self.init_ui()
# Set window icon
icon_path = os.path.join(os.path.dirname(__file__), 'icon.png')
if os.path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path))
self.audio_engine.start_stream()
self.fetch_library()
def init_ui(self):
self.setWindowTitle("TechDJ Pro - Native Edition")
self.setGeometry(50, 50, 1600, 900)
# Central widget with overlay support
central = QWidget()
self.setCentralWidget(central)
# Overall vertical layout for central widget
self.container_layout = QVBoxLayout(central)
self.container_layout.setContentsMargins(0, 0, 0, 0)
self.container_layout.setSpacing(0)
# --- Download Bar (Minimized) ---
self.download_bar = QWidget()
self.download_bar.setFixedHeight(38) # Reduced from 50
self.download_bar.setStyleSheet(f"""
QWidget {{
background: rgb(20, 20, 30);
border-bottom: 1px solid rgba({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}, 0.3);
}}
""")
dl_layout = QHBoxLayout(self.download_bar)
dl_layout.setContentsMargins(10, 2, 10, 2) # Tighten margins
self.dl_input = QLineEdit()
self.dl_input.setPlaceholderText("Paste URL or Type to Search (YT, SC, etc.)")
self.dl_input.setStyleSheet(f"""
QLineEdit {{
background: rgba(255, 255, 255, 0.05);
border: 1px solid #333;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-family: 'Rajdhani';
font-size: 12px;
}}
QLineEdit:focus {{ border: 1px solid rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); }}
""")
self.dl_input.returnPressed.connect(self.start_download)
dl_layout.addWidget(self.dl_input, 1)
self.dl_btn = NeonButton("GET", SECONDARY_MAGENTA) # Shorter text
self.dl_btn.setFixedSize(60, 26) # Smaller button
self.dl_btn.clicked.connect(self.start_download)
dl_layout.addWidget(self.dl_btn)
self.dl_progress = QProgressBar()
self.dl_progress.setFixedWidth(120)
self.dl_progress.setFixedHeight(4)
self.dl_progress.setTextVisible(False)
self.dl_progress.setStyleSheet(f"""
QProgressBar {{ background: #111; border: none; border-radius: 2px; }}
QProgressBar::chunk {{ background: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); }}
""")
self.dl_progress.hide()
dl_layout.addWidget(self.dl_progress)
self.container_layout.addWidget(self.download_bar)
# Main grid layout matching web panel
main_layout = QHBoxLayout()
# Create a widget to hold main_layout
self.app_content = QWidget()
self.app_content.setLayout(main_layout)
self.container_layout.addWidget(self.app_content, 1)
main_layout.setSpacing(10)
main_layout.setContentsMargins(10, 10, 10, 10)
# Left: Library (320px)
library_widget = QWidget()
library_widget.setFixedWidth(320)
library_widget.setStyleSheet(f"""
QWidget {{
background: rgba(20, 20, 30, 0.8);
border: 2px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()});
border-radius: 10px;
}}
""")
library_layout = QVBoxLayout(library_widget)
library_layout.setSpacing(10)
library_layout.setContentsMargins(15, 15, 15, 15)
lib_header = QLabel("📁 LIBRARY")
lib_header.setStyleSheet(f"""
font-family: 'Orbitron';
font-size: 16px;
font-weight: bold;
color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()});
border: none;
""")
library_layout.addWidget(lib_header)
# Library Mode Switch
mode_switch_layout = QHBoxLayout()
self.server_mode_btn = NeonButton("SERVER", PRIMARY_CYAN)
self.server_mode_btn.set_active(True)
self.server_mode_btn.clicked.connect(lambda: self.set_library_mode('server'))
self.local_mode_btn = NeonButton("LOCAL", TEXT_DIM)
self.local_mode_btn.clicked.connect(lambda: self.set_library_mode('local'))
mode_switch_layout.addWidget(self.server_mode_btn)
mode_switch_layout.addWidget(self.local_mode_btn)
library_layout.addLayout(mode_switch_layout)
# Local Folder Selection (hidden by default)
self.local_folder_widget = QWidget()
local_folder_layout = QHBoxLayout(self.local_folder_widget)
local_folder_layout.setContentsMargins(0, 0, 0, 0)
self.folder_label = QLabel("NO FOLDER...")
self.folder_label.setStyleSheet("color: #888; font-size: 10px;")
select_folder_btn = QPushButton("📁")
select_folder_btn.setFixedSize(30, 30)
select_folder_btn.setStyleSheet("background: #333; border-radius: 4px; color: white;")
select_folder_btn.clicked.connect(self.select_local_folder)
local_folder_layout.addWidget(self.folder_label, 1)
local_folder_layout.addWidget(select_folder_btn)
self.local_folder_widget.hide()
library_layout.addWidget(self.local_folder_widget)
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("🔍 FILTER LIBRARY...")
self.search_box.textChanged.connect(self.filter_library)
self.search_box.setStyleSheet("""
QLineEdit {
background: rgba(0, 0, 0, 0.3);
border: 1px solid #333;
color: white;
padding: 10px;
border-radius: 4px;
font-family: 'Rajdhani';
}
""")
library_layout.addWidget(self.search_box)
self.library_list = QListWidget()
self.library_list.setSpacing(4) # Add spacing between items
self.library_list.setStyleSheet("""
QListWidget {
background: rgba(0, 0, 0, 0.3);
border: none;
color: white;
outline: none;
}
QListWidget::item {
background: transparent;
border: none;
padding: 0px;
margin: 0px;
}
QListWidget::item:selected {
background: transparent;
}
""")
self.library_list.itemDoubleClicked.connect(self.on_library_double_click)
library_layout.addWidget(self.library_list)
refresh_btn = QPushButton("🔄 Refresh Library")
refresh_btn.clicked.connect(self.fetch_library)
refresh_btn.setStyleSheet(f"""
QPushButton {{
background: rgba(0, 243, 255, 0.1);
border: 1px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()});
color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()});
padding: 8px 12px;
border-radius: 4px;
font-family: 'Orbitron';
font-weight: bold;
}}
QPushButton:hover {{
background: rgba(0, 243, 255, 0.2);
}}
""")
library_layout.addWidget(refresh_btn)
main_layout.addWidget(library_widget)
# Right: Decks + Crossfader
decks_widget = QWidget()
decks_layout = QVBoxLayout(decks_widget)
decks_layout.setSpacing(10)
decks_layout.setContentsMargins(0, 0, 0, 0)
# Decks grid
decks_grid = QHBoxLayout()
decks_grid.setSpacing(10)
self.deck_a = DeckWidget('A', self.audio_engine)
decks_grid.addWidget(self.deck_a)
self.deck_b = DeckWidget('B', self.audio_engine)
decks_grid.addWidget(self.deck_b)
decks_layout.addLayout(decks_grid)
# Crossfader
xfader_widget = QWidget()
xfader_widget.setFixedHeight(80)
xfader_widget.setStyleSheet("""
QWidget {
background: qlineargradient(x1:0, y1:0, x1:0, y1:1,
stop:0 #1a1a1a, stop:1 #0a0a0a);
border: 2px solid #444;
border-radius: 8px;
}
""")
# Crossfader Bar (Full Width)
xfader_widget = QWidget()
xfader_widget.setFixedHeight(80)
xfader_widget.setStyleSheet("""
QWidget {
background: qlineargradient(x1:0, y1:0, x1:0, y1:1,
stop:0 #1a1a1a, stop:1 #0a0a0a);
border: 2px solid #444;
border-radius: 8px;
}
""")
xfader_layout = QHBoxLayout(xfader_widget)
xfader_layout.setContentsMargins(40, 15, 40, 15)
label_a = QLabel("A")
label_a.setStyleSheet(f"""
font-family: 'Orbitron';
font-size: 24px;
font-weight: bold;
color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()});
""")
xfader_layout.addWidget(label_a)
self.crossfader = QSlider(Qt.Horizontal)
self.crossfader.setRange(0, 100)
self.crossfader.setValue(50)
self.crossfader.valueChanged.connect(self.on_crossfader_change)
self.crossfader.setStyleSheet("""
QSlider::groove:horizontal {
height: 12px;
background: qlineargradient(x1:0, y1:0, x1:1, y1:0,
stop:0 #00f3ff, stop:0.5 #333, stop:1 #bc13fe);
border-radius: 6px;
border: 2px solid #555;
}
QSlider::handle:horizontal {
background: qlineargradient(x1:0, y1:0, x1:0, y1:1,
stop:0 #aaa, stop:1 #666);
border: 3px solid #ccc;
width: 80px;
height: 48px;
margin: -18px 0;
border-radius: 8px;
}
QSlider::handle:horizontal:hover {
background: qlineargradient(x1:0, y1:0, x1:0, y1:1,
stop:0 #ccc, stop:1 #888);
}
""")
xfader_layout.addWidget(self.crossfader, 1) # Give it stretch
label_b = QLabel("B")
label_b.setStyleSheet(f"""
font-family: 'Orbitron';
font-size: 24px;
font-weight: bold;
color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()});
""")
xfader_layout.addWidget(label_b)
decks_layout.addWidget(xfader_widget)
main_layout.addWidget(decks_widget, 1)
# Floating action buttons (bottom right)
self.create_floating_buttons()
# Streaming panel (hidden by default)
self.create_streaming_panel()
# Settings panel (hidden by default)
self.create_settings_panel()
# Window styling
self.setStyleSheet(f"""
QMainWindow {{
background: rgb({BG_DARK.red()}, {BG_DARK.green()}, {BG_DARK.blue()});
}}
QWidget {{
color: rgb({TEXT_MAIN.red()}, {TEXT_MAIN.green()}, {TEXT_MAIN.blue()});
font-family: 'Rajdhani', sans-serif;
}}
""")
# Glow effect timer
self.glow_timer = QTimer()
self.glow_timer.timeout.connect(self.update_glow_effect)
self.glow_timer.start(100)
def create_floating_buttons(self):
"""Create floating action buttons in bottom-right corner"""
button_style = """
QPushButton {
background: rgba(188, 19, 254, 0.2);
border: 2px solid #bc13fe;
color: white;
font-size: 20px;
border-radius: 25px;
padding: 10px;
}
QPushButton:hover {
background: rgba(188, 19, 254, 0.4);
}
"""
# Streaming button
self.streaming_btn = QPushButton("📡", self)
self.streaming_btn.setFixedSize(50, 50)
self.streaming_btn.setStyleSheet(button_style)
self.streaming_btn.clicked.connect(self.toggle_streaming_panel)
self.streaming_btn.setToolTip("Live Streaming")
self.streaming_btn.move(self.width() - 70, self.height() - 280)
# Settings button
self.settings_btn = QPushButton("⚙️", self)
self.settings_btn.setFixedSize(50, 50)
self.settings_btn.setStyleSheet(button_style)
self.settings_btn.clicked.connect(self.toggle_settings_panel)
self.settings_btn.setToolTip("Settings")
self.settings_btn.move(self.width() - 70, self.height() - 220)
# Upload button
self.upload_btn = QPushButton("📁", self)
self.upload_btn.setFixedSize(50, 50)
self.upload_btn.setStyleSheet(button_style)
self.upload_btn.clicked.connect(self.upload_file)
self.upload_btn.setToolTip("Upload MP3")
self.upload_btn.move(self.width() - 70, self.height() - 160)
# Keyboard shortcuts button
self.keyboard_btn = QPushButton("⌨️", self)
self.keyboard_btn.setFixedSize(50, 50)
self.keyboard_btn.setStyleSheet(button_style)
self.keyboard_btn.setToolTip("Keyboard Shortcuts")
self.keyboard_btn.move(self.width() - 70, self.height() - 100)
def create_streaming_panel(self):
"""Create streaming panel matching web version"""
self.streaming_panel = QWidget(self)
self.streaming_panel.setFixedSize(400, 500)
self.streaming_panel.setStyleSheet(f"""
QWidget {{
background: rgba(20, 20, 30, 0.95);
border: 2px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()});
border-radius: 10px;
}}
""")
self.streaming_panel.hide()
layout = QVBoxLayout(self.streaming_panel)
layout.setSpacing(15)
# Header
header = QHBoxLayout()
title = QLabel("📡 LIVE STREAM")
title.setStyleSheet(f"""
font-family: 'Orbitron';
font-size: 16px;
font-weight: bold;
color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()});
""")
header.addWidget(title)
header.addStretch()
close_btn = QPushButton("")
close_btn.setFixedSize(30, 30)
close_btn.clicked.connect(self.toggle_streaming_panel)
close_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: none;
color: #888;
font-size: 18px;
}
QPushButton:hover {
color: white;
}
""")
header.addWidget(close_btn)
layout.addLayout(header)
# Broadcast button
self.broadcast_btn = QPushButton("🔴 START BROADCAST")
self.broadcast_btn.setFixedHeight(60)
self.broadcast_btn.clicked.connect(self.toggle_broadcast)
self.broadcast_btn.setStyleSheet(f"""
QPushButton {{
background: rgba(255, 0, 0, 0.2);
border: 2px solid #ff0000;
color: #ff0000;
font-family: 'Orbitron';
font-size: 14px;
font-weight: bold;
border-radius: 8px;
}}
QPushButton:hover {{
background: rgba(255, 0, 0, 0.3);
}}
""")
layout.addWidget(self.broadcast_btn)
# Status
self.broadcast_status = QLabel("Offline")
self.broadcast_status.setAlignment(Qt.AlignCenter)
self.broadcast_status.setStyleSheet("color: #888; font-size: 12px;")
layout.addWidget(self.broadcast_status)
# Listener count
listener_widget = QWidget()
listener_layout = QHBoxLayout(listener_widget)
listener_layout.setContentsMargins(0, 0, 0, 0)
listener_icon = QLabel("👂")
listener_icon.setStyleSheet("font-size: 24px;")
listener_layout.addWidget(listener_icon)
self.listener_count_label = QLabel("0")
self.listener_count_label.setStyleSheet(f"""
font-family: 'Orbitron';
font-size: 32px;
font-weight: bold;
color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()});
""")
listener_layout.addWidget(self.listener_count_label)
listener_text = QLabel("Listeners")
listener_text.setStyleSheet("color: #888; font-size: 14px;")
listener_layout.addWidget(listener_text)
listener_layout.addStretch()
layout.addWidget(listener_widget)
# Stream URL
url_label = QLabel("Share this URL:")
url_label.setStyleSheet("color: #888; font-size: 12px;")
layout.addWidget(url_label)
url_widget = QWidget()
url_layout = QHBoxLayout(url_widget)
url_layout.setContentsMargins(0, 0, 0, 0)
url_layout.setSpacing(5)
self.stream_url = QLineEdit("http://localhost:5001")
self.stream_url.setReadOnly(True)
self.stream_url.setStyleSheet("""
QLineEdit {
background: rgba(0, 0, 0, 0.3);
border: 1px solid #333;
color: white;
padding: 8px;
border-radius: 4px;
}
""")
url_layout.addWidget(self.stream_url)
copy_btn = QPushButton("📋")
copy_btn.setFixedSize(40, 30)
copy_btn.clicked.connect(self.copy_stream_url)
copy_btn.setStyleSheet("""
QPushButton {
background: rgba(0, 243, 255, 0.1);
border: 1px solid #00f3ff;
color: #00f3ff;
}
QPushButton:hover {
background: rgba(0, 243, 255, 0.2);
}
""")
url_layout.addWidget(copy_btn)
layout.addWidget(url_widget)
# Auto-start checkbox
self.auto_start_check = QCheckBox("Auto-start on play")
self.auto_start_check.setStyleSheet("color: #e0e0e0;")
layout.addWidget(self.auto_start_check)
# Quality selector
quality_label = QLabel("Stream Quality:")
quality_label.setStyleSheet("color: #888; font-size: 12px;")
layout.addWidget(quality_label)
self.quality_combo = QComboBox()
self.quality_combo.addItems([
"High (128kbps)",
"Medium (96kbps)",
"Low (64kbps)",
"Very Low (48kbps)",
"Minimum (32kbps)"
])
self.quality_combo.setCurrentIndex(1)
self.quality_combo.setStyleSheet("""
QComboBox {
background: rgba(0, 0, 0, 0.3);
border: 1px solid #333;
color: white;
padding: 5px;
border-radius: 4px;
}
QComboBox::drop-down {
border: none;
}
QComboBox QAbstractItemView {
background: #1a1a1a;
color: white;
selection-background-color: #00f3ff;
}
""")
layout.addWidget(self.quality_combo)
hint = QLabel("Lower = more stable on poor connections")
hint.setStyleSheet("color: #666; font-size: 10px;")
layout.addWidget(hint)
layout.addStretch()
# Position panel
self.streaming_panel.move(self.width() - 420, 20)
def create_settings_panel(self):
"""Create settings panel with glow controls"""
self.settings_panel = QWidget(self)
self.settings_panel.setFixedSize(400, 600)
self.settings_panel.setStyleSheet(f"""
QWidget {{
background: rgba(20, 20, 30, 0.95);
border: 2px solid rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()});
border-radius: 10px;
}}
""")
self.settings_panel.hide()
layout = QVBoxLayout(self.settings_panel)
layout.setSpacing(10)
# Header
header = QHBoxLayout()
title = QLabel("⚙️ SETTINGS")
title.setStyleSheet(f"""
font-family: 'Orbitron';
font-size: 16px;
font-weight: bold;
color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()});
""")
header.addWidget(title)
header.addStretch()
close_btn = QPushButton("")
close_btn.setFixedSize(30, 30)
close_btn.clicked.connect(self.toggle_settings_panel)
close_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: none;
color: #888;
font-size: 18px;
}
QPushButton:hover {
color: white;
}
""")
header.addWidget(close_btn)
layout.addLayout(header)
# Settings checkboxes
checkbox_style = """
QCheckBox {
color: #e0e0e0;
font-size: 13px;
spacing: 8px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border: 2px solid #666;
border-radius: 3px;
background: rgba(0, 0, 0, 0.3);
}
QCheckBox::indicator:checked {
background: #bc13fe;
border-color: #bc13fe;
}
"""
self.repeat_a_check = QCheckBox("🔁 Repeat Deck A")
self.repeat_a_check.setStyleSheet(checkbox_style)
layout.addWidget(self.repeat_a_check)
self.repeat_b_check = QCheckBox("🔁 Repeat Deck B")
self.repeat_b_check.setStyleSheet(checkbox_style)
layout.addWidget(self.repeat_b_check)
self.auto_mix_check = QCheckBox("🎛️ Auto-Crossfade")
self.auto_mix_check.setStyleSheet(checkbox_style)
layout.addWidget(self.auto_mix_check)
self.shuffle_check = QCheckBox("🔀 Shuffle Library")
self.shuffle_check.setStyleSheet(checkbox_style)
layout.addWidget(self.shuffle_check)
self.quantize_check = QCheckBox("📐 Quantize")
self.quantize_check.setStyleSheet(checkbox_style)
layout.addWidget(self.quantize_check)
self.auto_play_check = QCheckBox("▶️ Auto-play next")
self.auto_play_check.setChecked(True)
self.auto_play_check.setStyleSheet(checkbox_style)
layout.addWidget(self.auto_play_check)
# Glow controls
layout.addWidget(QLabel("")) # Spacer
glow_title = QLabel("✨ NEON GLOW EFFECTS")
glow_title.setStyleSheet(f"""
font-family: 'Orbitron';
font-size: 14px;
font-weight: bold;
color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()});
""")
layout.addWidget(glow_title)
self.glow_a_check = QCheckBox("✨ Glow Deck A (Cyan)")
self.glow_a_check.setStyleSheet(checkbox_style)
self.glow_a_check.stateChanged.connect(lambda: self.toggle_glow('A'))
layout.addWidget(self.glow_a_check)
self.glow_b_check = QCheckBox("✨ Glow Deck B (Magenta)")
self.glow_b_check.setStyleSheet(checkbox_style)
self.glow_b_check.stateChanged.connect(lambda: self.toggle_glow('B'))
layout.addWidget(self.glow_b_check)
# Glow intensity
intensity_label = QLabel("✨ Glow Intensity")
intensity_label.setStyleSheet("color: #e0e0e0; font-size: 13px;")
layout.addWidget(intensity_label)
self.glow_slider = QSlider(Qt.Horizontal)
self.glow_slider.setRange(1, 100)
self.glow_slider.setValue(30)
self.glow_slider.valueChanged.connect(self.update_glow_intensity)
self.glow_slider.setStyleSheet("""
QSlider::groove:horizontal {
height: 8px;
background: #333;
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #bc13fe;
border: 2px solid #bc13fe;
width: 16px;
margin: -4px 0;
border-radius: 8px;
}
""")
layout.addWidget(self.glow_slider)
# Server URL configuration
layout.addWidget(QLabel("")) # Spacer
server_title = QLabel("📡 SERVER CONFIGURATION")
server_title.setStyleSheet(f"""
font-family: 'Orbitron';
font-size: 14px;
font-weight: bold;
color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()});
""")
layout.addWidget(server_title)
server_url_label = QLabel("🔗 Server API URL (e.g. http://localhost:5000)")
server_url_label.setStyleSheet("color: #e0e0e0; font-size: 13px;")
layout.addWidget(server_url_label)
self.server_url_input = QLineEdit(self.server_url)
self.server_url_input.setStyleSheet("""
background: rgba(0, 0, 0, 0.4);
border: 1px solid #444;
color: cyan;
padding: 5px;
font-family: 'Rajdhani';
border-radius: 4px;
""")
self.server_url_input.textChanged.connect(self.on_server_url_change)
layout.addWidget(self.server_url_input)
layout.addStretch()
# Position panel
self.settings_panel.move(self.width() - 420, 20)
def load_settings(self):
"""Load persistent settings"""
settings_path = Path.home() / ".techdj_settings.json"
if settings_path.exists():
try:
with open(settings_path, 'r') as f:
data = json.load(f)
self.local_folder = data.get('local_folder')
self.library_mode = data.get('library_mode', 'server')
self.server_url = data.get('server_url', self.server_url)
except Exception as e:
print(f"Error loading settings: {e}")
def save_settings(self):
"""Save persistent settings"""
settings_path = Path.home() / ".techdj_settings.json"
try:
with open(settings_path, 'w') as f:
json.dump({
'local_folder': self.local_folder,
'library_mode': self.library_mode,
'server_url': self.server_url
}, f)
except Exception as e:
print(f"Error saving settings: {e}")
def set_library_mode(self, mode):
"""Switch between server and local library"""
self.library_mode = mode
if mode == 'server':
self.server_mode_btn.set_active(True)
self.local_mode_btn.set_active(False)
self.local_folder_widget.hide()
else:
self.server_mode_btn.set_active(False)
self.local_mode_btn.set_active(True)
self.local_folder_widget.show()
if self.local_folder:
self.folder_label.setText(os.path.basename(self.local_folder).upper())
self.scan_local_library()
self.update_library_list(rebuild=True)
self.save_settings()
def select_local_folder(self):
"""Open dialog to select local music folder"""
folder = QFileDialog.getExistingDirectory(self, "Select Music Folder")
if folder:
self.local_folder = folder
self.folder_label.setText(os.path.basename(folder).upper())
self.scan_local_library()
self.update_library_list(rebuild=True)
self.save_settings()
def on_server_url_change(self, text):
"""Update server URL and save"""
self.server_url = text
self.save_settings()
# Debounce the refresh to avoid spamming while typing
if not hasattr(self, '_refresh_timer'):
self._refresh_timer = QTimer()
self._refresh_timer.timeout.connect(self.fetch_library)
self._refresh_timer.setSingleShot(True)
self._refresh_timer.start(1500) # Refresh library 1.5s after typing stops
def scan_local_library(self):
"""Scan local folder for audio files"""
if not self.local_folder:
return
self.local_library = []
extensions = ('.mp3', '.wav', '.flac', '.ogg', '.m4a')
try:
for root, dirs, files in os.walk(self.local_folder):
for file in sorted(files):
if file.lower().endswith(extensions):
full_path = os.path.join(root, file)
self.local_library.append({
"title": os.path.splitext(file)[0],
"file": full_path,
"is_local": True
})
print(f"📂 Found {len(self.local_library)} local tracks")
except Exception as e:
print(f"Error scanning folder: {e}")
def fetch_library(self):
try:
response = requests.get(f"{self.server_url}/library.json", timeout=5)
self.server_library = response.json()
# Mark server tracks
for track in self.server_library:
track['is_local'] = False
# Initial mode setup
self.set_library_mode(self.library_mode)
print(f"📚 Loaded {len(self.server_library)} tracks from server")
except Exception as e:
print(f"❌ Error fetching library: {e}")
# Still set local mode if server fails
self.set_library_mode(self.library_mode)
def update_library_list(self, rebuild=False):
"""Update library results. If rebuild is True, clear and recreate all widgets.
If rebuild is False, just hide/show existing items (much faster)."""
search_term = self.search_box.text().lower()
# Determine which library to show
library_to_show = self.server_library if self.library_mode == 'server' else self.local_library
# If we need a full rebuild or the list is empty/wrong size
if rebuild or self.library_list.count() != len(library_to_show):
self.library_list.setUpdatesEnabled(False)
self.library_list.clear()
for track in library_to_show:
item = QListWidgetItem()
item.setSizeHint(QSize(0, 40))
item.setData(Qt.UserRole, track)
widget = QWidget()
widget.setStyleSheet("""
QWidget {
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
border-left: 3px solid transparent;
}
QWidget:hover {
background: rgba(255, 255, 255, 0.08);
border-left: 3px solid #00f3ff;
}
""")
item_layout = QHBoxLayout(widget)
item_layout.setContentsMargins(10, 8, 10, 8)
item_layout.setSpacing(5)
label = QLabel(track['title'])
label.setStyleSheet("font-family: 'Rajdhani'; font-weight: bold; font-size: 13px; color: white; background: transparent;")
item_layout.addWidget(label, 1)
btn_a = QPushButton("A+")
btn_a.setFixedSize(30, 22)
btn_a.setStyleSheet(f"background: rgba(0, 243, 255, 0.2); border: 1px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); border-radius: 4px; color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); font-size: 9px; font-weight: bold;")
btn_a.clicked.connect(lambda _, t=track: self.add_to_queue('A', t))
item_layout.addWidget(btn_a)
btn_b = QPushButton("B+")
btn_b.setFixedSize(30, 22)
btn_b.setStyleSheet(f"background: rgba(188, 19, 254, 0.2); border: 1px solid rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); border-radius: 4px; color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); font-size: 9px; font-weight: bold;")
btn_b.clicked.connect(lambda _, t=track: self.add_to_queue('B', t))
item_layout.addWidget(btn_b)
self.library_list.addItem(item)
self.library_list.setItemWidget(item, widget)
self.library_list.setUpdatesEnabled(True)
# Apply visibility filter (extremely fast)
self.library_list.setUpdatesEnabled(False)
for i in range(self.library_list.count()):
item = self.library_list.item(i)
track = item.data(Qt.UserRole)
if track:
visible = not search_term or search_term in track['title'].lower()
item.setHidden(not visible)
self.library_list.setUpdatesEnabled(True)
def filter_library(self):
# Debounce reduced to 100ms for snappier feel
self.search_timer.start(100)
def on_library_double_click(self, item):
track = item.data(Qt.UserRole)
dialog = QDialog(self)
dialog.setWindowTitle("Load Track")
dialog.setStyleSheet(f"""
QDialog {{
background: rgb({BG_DARK.red()}, {BG_DARK.green()}, {BG_DARK.blue()});
}}
""")
layout = QVBoxLayout()
layout.addWidget(QLabel(f"Load '{track['title']}' to:"))
btn_a = NeonButton(f"▶ Play on Deck A", PRIMARY_CYAN)
btn_a.clicked.connect(lambda: self.load_to_deck('A', track, dialog))
layout.addWidget(btn_a)
btn_b = NeonButton(f"▶ Play on Deck B", SECONDARY_MAGENTA)
btn_b.clicked.connect(lambda: self.load_to_deck('B', track, dialog))
layout.addWidget(btn_b)
# Add to queue buttons
queue_a = NeonButton(f"📋 Add to Queue A", PRIMARY_CYAN)
queue_a.clicked.connect(lambda: self.add_to_queue('A', track, dialog))
layout.addWidget(queue_a)
queue_b = NeonButton(f"📋 Add to Queue B", SECONDARY_MAGENTA)
queue_b.clicked.connect(lambda: self.add_to_queue('B', track, dialog))
layout.addWidget(queue_b)
dialog.setLayout(layout)
dialog.exec_()
def load_to_deck(self, deck_id, track, dialog=None):
if dialog:
dialog.accept()
if track.get('is_local'):
# Load local file directly
print(f"📂 Loading local: {track['file']}")
self.deck_loading_target[deck_id] = track['file']
if deck_id == 'A':
self.deck_a.load_track(track['file'])
else:
self.deck_b.load_track(track['file'])
return
filename = os.path.basename(track['file'])
cache_path = self.cache_dir / filename
self.deck_loading_target[deck_id] = str(cache_path)
if cache_path.exists():
print(f"📦 Using cached: {filename}")
if deck_id == 'A':
self.deck_a.load_track(str(cache_path))
else:
self.deck_b.load_track(str(cache_path))
else:
url = f"{self.server_url}/{track['file']}"
print(f"⬇️ Downloading: {filename}")
thread = DownloadThread(url, str(cache_path))
thread.finished.connect(lambda path, success: self.on_download_finished(deck_id, path, success))
thread.start()
self.download_threads[filename] = thread
def on_download_finished(self, deck_id, filepath, success):
if success:
# Check if this is still the intended track for this deck
if self.deck_loading_target.get(deck_id) != filepath:
print(f"⏭️ Stale download finished (ignored): {os.path.basename(filepath)}")
return
print(f"✅ Downloaded: {os.path.basename(filepath)}")
if deck_id == 'A':
self.deck_a.load_track(filepath)
else:
self.deck_b.load_track(filepath)
else:
QMessageBox.warning(self, "Download Error", "Failed to download track")
def add_to_queue(self, deck_id, track, dialog=None):
"""Add track to deck's queue"""
if dialog:
dialog.accept()
# Determine file path
if self.library_mode == 'local':
filepath = track['file']
else:
filename = track['file'].split('/')[-1]
cache_path = self.cache_dir / filename
if cache_path.exists():
filepath = str(cache_path)
else:
# Download to cache first
url = f"{self.server_url}/{track['file']}"
print(f"⬇️ Downloading for queue: {filename}")
thread = DownloadThread(url, str(cache_path))
thread.finished.connect(lambda path, success: self.on_queue_download_finished(deck_id, path, success))
thread.start()
self.download_threads[filename] = thread
return
# Add to queue
self.audio_engine.add_to_queue(deck_id, filepath)
queue_len = len(self.audio_engine.get_queue(deck_id))
print(f"📋 Added to Deck {deck_id} queue: {track['title']} (Queue: {queue_len})")
if dialog:
QMessageBox.information(self, "Added to Queue",
f"Added '{track['title']}' to Deck {deck_id} queue\n\nQueue length: {queue_len}")
def on_queue_download_finished(self, deck_id, filepath, success):
"""Handle download completion for queued tracks"""
if success:
self.audio_engine.add_to_queue(deck_id, filepath)
queue_len = len(self.audio_engine.get_queue(deck_id))
print(f"✅ Downloaded and queued: {os.path.basename(filepath)} (Queue: {queue_len})")
else:
print(f"❌ Failed to download for queue: {os.path.basename(filepath)}")
def on_crossfader_change(self, value):
self.audio_engine.set_crossfader(value / 100.0)
def toggle_streaming_panel(self):
"""Toggle streaming panel visibility"""
if self.streaming_panel.isVisible():
self.streaming_panel.hide()
else:
self.settings_panel.hide() # Hide settings if open
self.streaming_panel.show()
self.streaming_panel.raise_()
def toggle_settings_panel(self):
"""Toggle settings panel visibility"""
if self.settings_panel.isVisible():
self.settings_panel.hide()
else:
self.streaming_panel.hide() # Hide streaming if open
self.settings_panel.show()
self.settings_panel.raise_()
def toggle_broadcast(self):
"""Toggle broadcast on/off"""
if not self.broadcasting:
# Start broadcast
try:
if self.socket is None:
print(f"🔌 Connecting to server: {self.server_url}")
self.socket = socketio.Client(logger=True, engineio_logger=False)
# Add connection event handlers
@self.socket.on('connect')
def on_connect():
print("✅ Socket.IO connected successfully")
@self.socket.on('connect_error')
def on_connect_error(data):
print(f"❌ Socket.IO connection error: {data}")
QMessageBox.warning(self, "Connection Error",
f"Failed to connect to server at {self.server_url}\n\nError: {data}")
@self.socket.on('disconnect')
def on_disconnect():
print("⚠️ Socket.IO disconnected")
self.socket.on('listener_count', self.on_listener_count)
try:
self.socket.connect(self.server_url, wait_timeout=10)
print("✅ Connection established")
except Exception as e:
print(f"❌ Connection failed: {e}")
QMessageBox.critical(self, "Connection Failed",
f"Could not connect to {self.server_url}\n\nError: {str(e)}\n\nMake sure the server is running.")
return
bitrate_map = {0: "128k", 1: "96k", 2: "64k", 3: "48k", 4: "32k"}
bitrate = bitrate_map.get(self.quality_combo.currentIndex(), "96k")
print(f"📡 Emitting start_broadcast with bitrate: {bitrate}")
self.socket.emit('start_broadcast', {
'bitrate': bitrate,
'format': 'mp3'
})
# Start local encoding thread
self.audio_engine.is_broadcasting = True
self.broadcast_thread = BroadcastThread(self.audio_engine.broadcast_queue, bitrate)
self.broadcast_thread.chunk_ready.connect(self.on_broadcast_chunk)
self.broadcast_thread.start()
self.broadcasting = True
self.broadcast_btn.setText("🟢 STOP BROADCAST")
self.broadcast_btn.setStyleSheet("""
QPushButton {
background: rgba(0, 255, 0, 0.2);
border: 2px solid #00ff00;
color: #00ff00;
font-family: 'Orbitron';
font-size: 14px;
font-weight: bold;
border-radius: 8px;
}
QPushButton:hover {
background: rgba(0, 255, 0, 0.3);
}
""")
self.broadcast_status.setText("🔴 LIVE")
self.broadcast_status.setStyleSheet("color: #00ff00; font-size: 12px; font-weight: bold;")
print("🎙️ Broadcast started")
except Exception as e:
print(f"❌ Broadcast error: {e}")
QMessageBox.warning(self, "Broadcast Error", f"Could not start broadcast:\n{e}")
else:
# Stop broadcast
if self.socket and self.socket.connected:
try:
self.socket.emit('stop_broadcast')
except Exception as e:
print(f"❌ Failed to emit stop_broadcast: {e}")
self.audio_engine.is_broadcasting = False
if self.broadcast_thread:
self.broadcast_thread.stop()
self.broadcast_thread = None
self.broadcasting = False
self.broadcast_btn.setText("🔴 START BROADCAST")
self.broadcast_btn.setStyleSheet("""
QPushButton {
background: rgba(255, 0, 0, 0.2);
border: 2px solid #ff0000;
color: #ff0000;
font-family: 'Orbitron';
font-size: 14px;
font-weight: bold;
border-radius: 8px;
}
QPushButton:hover {
background: rgba(255, 0, 0, 0.3);
}
""")
self.broadcast_status.setText("Offline")
self.broadcast_status.setStyleSheet("color: #888; font-size: 12px;")
print("🛑 Broadcast stopped")
def on_broadcast_chunk(self, chunk):
"""Send encoded chunk to server via Socket.IO"""
if self.socket and self.socket.connected and self.broadcasting:
try:
self.socket.emit('audio_chunk', chunk)
except Exception as e:
print(f"❌ Failed to send chunk: {e}")
def on_listener_count(self, data):
"""Update listener count from server"""
self.listener_count = data.get('count', 0)
# Update UI if streaming panel is visible
if hasattr(self, 'listener_count_label'):
self.listener_count_label.setText(f"{self.listener_count}")
def copy_stream_url(self):
"""Copy stream URL to clipboard"""
clipboard = QApplication.clipboard()
clipboard.setText(self.stream_url.text())
# Show feedback
original_text = self.stream_url.text()
self.stream_url.setText("✅ Copied!")
QTimer.singleShot(1000, lambda: self.stream_url.setText(original_text))
def toggle_glow(self, deck_id):
"""Toggle glow effect for a deck"""
if deck_id == 'A':
self.glow_enabled['A'] = self.glow_a_check.isChecked()
else:
self.glow_enabled['B'] = self.glow_b_check.isChecked()
print(f"✨ Glow {deck_id}: {self.glow_enabled[deck_id]}")
def update_glow_intensity(self, value):
"""Update glow intensity"""
self.glow_intensity = value
def update_glow_effect(self):
"""Update window glow effect based on settings"""
# This would apply a glow effect to the window border
# For now, just update deck styling
for deck_id in ['A', 'B']:
if self.glow_enabled[deck_id]:
deck_widget = self.deck_a if deck_id == 'A' else self.deck_b
color = PRIMARY_CYAN if deck_id == 'A' else SECONDARY_MAGENTA
opacity = self.glow_intensity / 100.0
# Apply glow effect (simplified - could be enhanced with QGraphicsEffect)
deck_widget.setStyleSheet(deck_widget.styleSheet() + f"""
QWidget#deck {{
box-shadow: 0 0 {self.glow_intensity}px rgba({color.red()}, {color.green()}, {color.blue()}, {opacity});
}}
""")
def start_download(self):
"""Search or start direct download"""
query = self.dl_input.text().strip()
if not query:
return
# Determine if it's a URL or search query
is_url = re.match(r'^https?://', query)
if is_url:
self.perform_actual_download(query)
else:
self.search_youtube(query)
def search_youtube(self, query):
"""Perform metadata search for youtube results"""
self.dl_input.setEnabled(False)
self.dl_btn.setEnabled(False)
self.dl_btn.setText("SEARCHING...")
venv_path = os.path.join(os.path.dirname(__file__), ".venv/bin/yt-dlp")
yt_dlp_cmd = venv_path if os.path.exists(venv_path) else "yt-dlp"
cmd = [
yt_dlp_cmd,
f"ytsearch8:{query}",
"--print", "%(title)s ||| %(duration_string)s ||| %(webpage_url)s",
"--no-playlist",
"--flat-playlist"
]
print(f"🔍 Searching YouTube: {query}")
self.search_process = QProcess()
self.search_process.finished.connect(self.on_search_finished)
self.search_process.start(cmd[0], cmd[1:])
def on_search_finished(self):
"""Handle search results and show dialog"""
self.dl_input.setEnabled(True)
self.dl_btn.setEnabled(True)
self.dl_btn.setText("GET")
# Check for errors
if self.search_process.exitCode() != 0:
err = str(self.search_process.readAllStandardError(), encoding='utf-8')
print(f"❌ YouTube Search Error: {err}")
QMessageBox.warning(self, "Search Error", f"YouTube search failed:\n\n{err[:200]}...")
return
output = str(self.search_process.readAllStandardOutput(), encoding='utf-8').strip()
if not output:
QMessageBox.warning(self, "No Results", "No YouTube results found for that query.")
return
results = [r for r in output.split("\n") if " ||| " in r]
if not results:
QMessageBox.warning(self, "No Results", "Could not parse search results.")
return
dialog = YouTubeSearchDialog(results, self)
dialog.item_selected.connect(self.perform_actual_download)
dialog.exec_()
def perform_actual_download(self, url):
"""Start the actual yt-dlp download process"""
# Use local folder or default to project's 'music' folder
dl_dir = self.local_folder if self.local_folder else "music"
if not os.path.exists(dl_dir):
os.makedirs(dl_dir, exist_ok=True)
# Disable input during download
self.dl_input.setEnabled(False)
self.dl_btn.setEnabled(False)
self.dl_progress.setValue(0)
self.dl_progress.show()
venv_path = os.path.join(os.path.dirname(__file__), ".venv/bin/yt-dlp")
yt_dlp_cmd = venv_path if os.path.exists(venv_path) else "yt-dlp"
cmd = [
yt_dlp_cmd,
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", "0",
"--output", f"{dl_dir}/%(title)s.%(ext)s",
"--no-playlist",
url
]
print(f"📥 Starting download: {url}")
self.dl_process = QProcess()
self.dl_process.readyReadStandardOutput.connect(self.on_dl_ready_read)
self.dl_process.finished.connect(self.on_dl_finished)
self.dl_process.start(cmd[0], cmd[1:])
def on_dl_ready_read(self):
"""Parse yt-dlp output for progress"""
output = str(self.dl_process.readAllStandardOutput(), encoding='utf-8')
# Look for [download] 45.3% of 10.00MiB at 10.00MiB/s ETA 00:00
match = re.search(r'\[download\]\s+(\d+\.\d+)%', output)
if match:
percent = float(match.group(1))
self.dl_progress.setValue(int(percent))
def on_dl_finished(self):
"""Handle download completion"""
self.dl_input.setEnabled(True)
self.dl_btn.setEnabled(True)
self.dl_progress.hide()
if self.dl_process.exitCode() == 0:
print("✅ Download finished successfully")
self.dl_input.clear()
self.fetch_library() # Refresh library to show new track
QMessageBox.information(self, "Download Complete", "Track downloaded and added to library!")
else:
err = str(self.dl_process.readAllStandardError(), encoding='utf-8')
if not err:
err = "Unknown error (check console)"
print(f"❌ Download failed: {err}")
QMessageBox.warning(self, "Download Failed", f"Error: {err}")
def upload_file(self):
"""Upload MP3 file to server"""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Upload MP3",
"",
"MP3 Files (*.mp3);;All Files (*)"
)
if file_path:
try:
filename = os.path.basename(file_path)
with open(file_path, 'rb') as f:
files = {'file': (filename, f, 'audio/mpeg')}
response = requests.post(f"{self.server_url}/upload", files=files)
if response.json().get('success'):
print(f"✅ Uploaded: {filename}")
QMessageBox.information(self, "Upload Success", f"Uploaded {filename}")
self.fetch_library() # Refresh library
else:
error = response.json().get('error', 'Unknown error')
QMessageBox.warning(self, "Upload Failed", error)
except Exception as e:
print(f"❌ Upload error: {e}")
QMessageBox.warning(self, "Upload Error", str(e))
def resizeEvent(self, event):
"""Handle window resize to reposition floating elements"""
super().resizeEvent(event)
# Reposition floating buttons
if hasattr(self, 'streaming_btn'):
self.streaming_btn.move(self.width() - 70, self.height() - 280)
self.settings_btn.move(self.width() - 70, self.height() - 220)
self.upload_btn.move(self.width() - 70, self.height() - 160)
self.keyboard_btn.move(self.width() - 70, self.height() - 100)
# Reposition panels
if hasattr(self, 'streaming_panel'):
self.streaming_panel.move(self.width() - 420, 20)
self.settings_panel.move(self.width() - 420, 20)
def closeEvent(self, event):
"""Clean up resources before closing"""
# Stop broadcast if active
if self.broadcasting:
self.toggle_broadcast()
# Disconnect Socket.IO
if self.socket and self.socket.connected:
try:
self.socket.disconnect()
print("🔌 Socket.IO disconnected")
except Exception as e:
print(f"⚠️ Error disconnecting Socket.IO: {e}")
# Stop audio engine
self.audio_engine.stop_stream()
# Wait for download threads to finish
for filename, thread in list(self.download_threads.items()):
if thread.isRunning():
thread.wait(1000) # Wait up to 1 second
event.accept()
def main():
app = QApplication(sys.argv)
app.setStyle('Fusion')
# Set dark palette
palette = app.palette()
palette.setColor(palette.Window, BG_DARK)
palette.setColor(palette.WindowText, TEXT_MAIN)
palette.setColor(palette.Base, QColor(15, 15, 20))
palette.setColor(palette.AlternateBase, QColor(20, 20, 30))
palette.setColor(palette.Text, TEXT_MAIN)
palette.setColor(palette.Button, QColor(30, 30, 40))
palette.setColor(palette.ButtonText, TEXT_M AIN)
app.setPalette(palette)
window = TechDJMainWindow()
window.show()