2097 lines
76 KiB
Python
2097 lines
76 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
|
|
from PyQt5.QtGui import (QPainter, QColor, QPen, QFont, QLinearGradient,
|
|
QRadialGradient, QBrush, QPainterPath, QFontDatabase)
|
|
import socketio
|
|
import queue
|
|
import subprocess
|
|
import time
|
|
import threading
|
|
|
|
|
|
# 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
|
|
},
|
|
'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
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
# Pre-allocate reuse buffers for the audio thread
|
|
self._target_indices = np.arange(2048, dtype=np.float32) # Matches blocksize
|
|
|
|
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
|
|
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']):
|
|
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
|
|
|
|
|
|
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 (4KB = ~0.15s @ 192k)
|
|
buffer_size = 4096
|
|
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()
|
|
|
|
# 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
|
|
|
|
print(f"📡 FFmpeg broadcast process started ({self.bitrate})")
|
|
|
|
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
|
|
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.setMinimumHeight(80)
|
|
self.setStyleSheet("background: #000; border: 1px solid #333; border-radius: 4px;")
|
|
|
|
def set_waveform(self, audio_data, sample_rate):
|
|
if audio_data is None:
|
|
self.waveform_data = []
|
|
return
|
|
|
|
samples = 1000
|
|
if audio_data.ndim > 1:
|
|
audio_data = np.mean(audio_data, axis=1)
|
|
|
|
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]
|
|
self.waveform_data.append(np.max(np.abs(chunk)))
|
|
|
|
self.update()
|
|
|
|
def set_position(self, position, 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)
|
|
painter.setBrush(wave_color)
|
|
|
|
for i, amplitude in enumerate(self.waveform_data):
|
|
x = i * bar_width
|
|
bar_height = amplitude * height * 5
|
|
y = (height - bar_height) / 2
|
|
painter.drawRect(int(x), int(y), max(1, int(bar_width)), int(bar_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(50) # 20 FPS
|
|
else:
|
|
self.timer.stop()
|
|
self.update()
|
|
|
|
def rotate(self):
|
|
self.rotation = (self.rotation + 5) % 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(50)
|
|
|
|
def init_ui(self):
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(8)
|
|
layout.setContentsMargins(10, 10, 10, 10)
|
|
|
|
# Header
|
|
header = QHBoxLayout()
|
|
title = QLabel(f"DECK {self.deck_id}")
|
|
title.setStyleSheet(f"""
|
|
font-family: 'Orbitron';
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()});
|
|
""")
|
|
header.addWidget(title)
|
|
|
|
self.track_label = QLabel("NO TRACK LOADED")
|
|
self.track_label.setStyleSheet("color: #888; font-size: 12px;")
|
|
header.addWidget(self.track_label, 1)
|
|
layout.addLayout(header)
|
|
|
|
# Waveform
|
|
waveform_container = QWidget()
|
|
waveform_container.setStyleSheet("background: #000; border: 1px solid #333; border-radius: 4px; padding: 3px;")
|
|
waveform_layout = QVBoxLayout(waveform_container)
|
|
waveform_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
self.waveform = WaveformWidget(self.deck_id, self)
|
|
waveform_layout.addWidget(self.waveform)
|
|
|
|
time_layout = QHBoxLayout()
|
|
self.time_label = QLabel("0:00 / 0:00")
|
|
self.time_label.setStyleSheet(f"""
|
|
color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()});
|
|
font-family: 'Orbitron';
|
|
font-size: 11px;
|
|
""")
|
|
time_layout.addWidget(self.time_label)
|
|
time_layout.addStretch()
|
|
self.bpm_label = QLabel("")
|
|
self.bpm_label.setStyleSheet("color: #f0f; font-weight: bold; font-size: 11px;")
|
|
time_layout.addWidget(self.bpm_label)
|
|
waveform_layout.addLayout(time_layout)
|
|
|
|
layout.addWidget(waveform_container)
|
|
|
|
# Vinyl disk
|
|
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)
|
|
|
|
# VU Meter canvas (placeholder)
|
|
self.vu_canvas = QWidget()
|
|
self.vu_canvas.setFixedHeight(60)
|
|
self.vu_canvas.setStyleSheet("background: #000; border: 1px solid #333; border-radius: 4px;")
|
|
layout.addWidget(self.vu_canvas)
|
|
|
|
# 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_out = NeonButton("LOOP OUT", QColor(255, 102, 0))
|
|
loop_exit = NeonButton("EXIT", QColor(255, 102, 0))
|
|
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)
|
|
|
|
for band in ['HI', 'MID', 'LO']:
|
|
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))
|
|
|
|
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())
|
|
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())
|
|
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_plus = QPushButton("+")
|
|
bend_plus.setFixedSize(30, 25)
|
|
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)
|
|
transport.addWidget(sync_btn)
|
|
|
|
reset_btn = NeonButton("🔄 RESET")
|
|
reset_btn.clicked.connect(self.reset_deck)
|
|
transport.addWidget(reset_btn)
|
|
|
|
layout.addLayout(transport)
|
|
|
|
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)
|
|
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 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"""
|
|
# Reset volume to 80%
|
|
self.volume_slider.setValue(80)
|
|
|
|
# Reset speed to 100%
|
|
self.speed_slider.setValue(100)
|
|
|
|
# Reset EQ to 0 (just update the engine, sliders will update via signals)
|
|
self.audio_engine.set_eq(self.deck_id, 'high', 0)
|
|
self.audio_engine.set_eq(self.deck_id, 'mid', 0)
|
|
self.audio_engine.set_eq(self.deck_id, 'low', 0)
|
|
|
|
# Reset filters
|
|
self.audio_engine.set_filter(self.deck_id, 'lowpass', 100)
|
|
self.audio_engine.set_filter(self.deck_id, 'highpass', 0)
|
|
|
|
print(f"🔄 Deck {self.deck_id} reset to defaults")
|
|
|
|
def update_display(self):
|
|
deck = self.audio_engine.decks[self.deck_id]
|
|
position = self.audio_engine.get_position(self.deck_id)
|
|
duration = deck['duration']
|
|
|
|
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'])
|
|
|
|
|
|
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()
|
|
|
|
self.init_ui()
|
|
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)
|
|
|
|
# Main grid layout matching web panel
|
|
main_layout = QHBoxLayout()
|
|
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.setStyleSheet("""
|
|
QListWidget {
|
|
background: rgba(0, 0, 0, 0.3);
|
|
border: none;
|
|
color: white;
|
|
}
|
|
QListWidget::item {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
margin-bottom: 8px;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
border-left: 3px solid transparent;
|
|
}
|
|
QListWidget::item:hover {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-left: 3px solid #00f3ff;
|
|
}
|
|
""")
|
|
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;
|
|
}
|
|
""")
|
|
|
|
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: 60px;
|
|
height: 40px;
|
|
margin: -15px 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)
|
|
|
|
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)
|
|
|
|
central.setLayout(main_layout)
|
|
|
|
# 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()
|
|
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()
|
|
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):
|
|
self.library_list.clear()
|
|
search_term = self.search_box.text().lower()
|
|
|
|
library_to_show = self.server_library if self.library_mode == 'server' else self.local_library
|
|
|
|
for track in library_to_show:
|
|
if search_term and search_term not in track['title'].lower():
|
|
continue
|
|
|
|
item = QListWidgetItem(track['title'])
|
|
item.setData(Qt.UserRole, track)
|
|
|
|
# Color coding for local vs server (optional visibility)
|
|
if self.library_mode == 'local':
|
|
item.setForeground(PRIMARY_CYAN)
|
|
|
|
self.library_list.addItem(item)
|
|
|
|
def filter_library(self):
|
|
self.update_library_list()
|
|
|
|
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"Deck A", PRIMARY_CYAN)
|
|
btn_a.clicked.connect(lambda: self.load_to_deck('A', track, dialog))
|
|
layout.addWidget(btn_a)
|
|
|
|
btn_b = NeonButton(f"Deck B", SECONDARY_MAGENTA)
|
|
btn_b.clicked.connect(lambda: self.load_to_deck('B', track, dialog))
|
|
layout.addWidget(btn_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 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:
|
|
self.socket.emit('stop_broadcast')
|
|
|
|
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.broadcasting:
|
|
self.socket.emit('audio_chunk', chunk)
|
|
|
|
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 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):
|
|
self.audio_engine.stop_stream()
|
|
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_MAIN)
|
|
app.setPalette(palette)
|
|
|
|
window = TechDJMainWindow()
|
|
window.show()
|
|
|
|
sys.exit(app.exec_())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|