techdj/techdj_qt.py

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()