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