2184 lines
87 KiB
Python
2184 lines
87 KiB
Python
#!/usr/bin/env python3
|
|
import sys
|
|
import os
|
|
import json
|
|
import random
|
|
import math
|
|
import time
|
|
import shutil
|
|
import requests
|
|
import re
|
|
import socketio
|
|
import subprocess
|
|
from pathlib import Path
|
|
import soundfile as sf
|
|
|
|
# --- BACKEND OVERRIDE ---
|
|
# On Linux, GStreamer (default) often miscalculates MP3 duration for VBR files.
|
|
# FFmpeg backend is much more reliable if available.
|
|
os.environ["QT_MULTIMEDIA_BACKEND"] = "ffmpeg"
|
|
|
|
# --- DEPENDENCY CHECK ---
|
|
try:
|
|
import yt_dlp
|
|
HAS_YTDLP = True
|
|
except ImportError:
|
|
HAS_YTDLP = False
|
|
print("CRITICAL: yt-dlp not found. Run 'pip install yt-dlp'")
|
|
|
|
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
|
QHBoxLayout, QPushButton, QSlider, QLabel,
|
|
QListWidget, QGroupBox, QListWidgetItem,
|
|
QLineEdit, QGridLayout, QAbstractItemView,
|
|
QDialog, QMessageBox, QFrame, QComboBox, QProgressBar,
|
|
QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
|
|
QCheckBox, QSpinBox, QFileDialog)
|
|
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
|
from PyQt6.QtCore import Qt, QUrl, QTimer, QPointF, QRectF, pyqtSignal, QProcess, QThread
|
|
from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QKeySequence, QIcon, QRadialGradient, QPainterPath, QShortcut
|
|
|
|
# --- CONFIGURATION ---
|
|
DEFAULT_BPM = 124
|
|
ANIMATION_FPS = 30
|
|
ANIMATION_INTERVAL = 1000 // ANIMATION_FPS # ms between animation frames
|
|
LOOP_CHECK_INTERVAL = 20 # ms between loop boundary checks
|
|
MS_PER_MINUTE = 60000
|
|
NUM_EQ_BANDS = 3
|
|
MAX_SLIDER_VALUE = 100
|
|
|
|
STYLESHEET = """
|
|
QMainWindow { background-color: #050505; }
|
|
QGroupBox { background-color: #0a0a0a; border-radius: 6px; margin-top: 10px; font-family: "Courier New"; }
|
|
QGroupBox#Deck_A { border: 2px solid #00ffff; }
|
|
QGroupBox#Deck_A::title { color: #00ffff; font-weight: bold; subcontrol-origin: margin; left: 10px; }
|
|
QGroupBox#Deck_B { border: 2px solid #ff00ff; }
|
|
QGroupBox#Deck_B::title { color: #ff00ff; font-weight: bold; subcontrol-origin: margin; left: 10px; }
|
|
QPushButton { background-color: #000; color: #fff; border: 1px solid #444; padding: 6px; font-weight: bold; border-radius: 4px; }
|
|
QPushButton:hover { background-color: #222; border: 1px solid #fff; }
|
|
QPushButton:pressed { background-color: #444; }
|
|
QPushButton#btn_neon { font-family: "Courier New"; margin-bottom: 5px; font-size: 12px; }
|
|
QPushButton#btn_yt_go { background-color: #cc0000; border: 1px solid #ff0000; color: white; font-weight: bold; }
|
|
QPushButton#btn_yt_go:hover { background-color: #ff0000; }
|
|
QPushButton#btn_remove { background-color: #330000; color: #ff0000; border: 1px solid #550000; padding: 0px; font-size: 10px; min-width: 20px; min-height: 20px; }
|
|
QPushButton#btn_remove:hover { background-color: #ff0000; color: #fff; border-color: #ff5555; }
|
|
QPushButton#btn_loop { background-color: #1a1a1a; color: #888; border: 1px solid #333; font-size: 11px; }
|
|
QPushButton#btn_loop:hover { border-color: #ffa500; color: #ffa500; }
|
|
QPushButton#btn_loop:checked { background-color: #ffa500; color: #000; border: 1px solid #ffcc00; }
|
|
QPushButton#btn_loop_exit { color: #ff3333; border: 1px solid #550000; font-size: 11px; }
|
|
QPushButton#btn_loop_exit:hover { background-color: #330000; border-color: #ff0000; }
|
|
QPushButton[mode="0"] { color: #00ff00; border-color: #005500; }
|
|
QPushButton[mode="1"] { color: #ffa500; border-color: #553300; }
|
|
QPushButton#btn_lib_local { color: #00ffff; border-color: #008888; }
|
|
QPushButton#btn_lib_local:checked { background-color: #00ffff; color: #000; font-weight: bold; }
|
|
QPushButton#btn_lib_server { color: #ff00ff; border-color: #880088; }
|
|
QPushButton#btn_lib_server:checked { background-color: #ff00ff; color: #000; font-weight: bold; }
|
|
QPushButton[mode="2"] { color: #ff0000; border-color: #550000; }
|
|
QLineEdit { background-color: #111; color: #fff; border: 1px solid #555; padding: 6px; font-family: "Courier New"; }
|
|
QLineEdit:focus { border: 1px solid #00ff00; }
|
|
QListWidget { background-color: #000; border: 1px solid #333; color: #888; font-family: "Courier New"; }
|
|
QListWidget::item:selected { background-color: #222; color: #fff; border: 1px solid #00ff00; }
|
|
QListWidget#queue_list::item:selected { background-color: #331111; color: #ffaaaa; border: 1px solid #550000; }
|
|
QSlider::groove:horizontal { border: 1px solid #333; height: 4px; background: #222; }
|
|
QSlider::handle:horizontal { background: #fff; border: 2px solid #fff; width: 14px; height: 14px; margin: -6px 0; border-radius: 8px; }
|
|
QSlider::groove:vertical { border: 1px solid #333; width: 6px; background: #111; border-radius: 3px; }
|
|
QSlider::handle:vertical { background: #ccc; border: 1px solid #fff; height: 14px; width: 14px; margin: 0 -5px; border-radius: 4px; }
|
|
QSlider::sub-page:vertical { background: #444; border-radius: 3px; }
|
|
QSlider::add-page:vertical { background: #222; border-radius: 3px; }
|
|
QSlider[eq="vol"]::handle:vertical { background: #fff; border: 1px solid #fff; }
|
|
QSlider[eq="high"]::handle:vertical { background: #00ffff; border: 1px solid #00ffff; }
|
|
QSlider[eq="mid"]::handle:vertical { background: #00ff00; border: 1px solid #00ff00; }
|
|
QSlider[eq="low"]::handle:vertical { background: #ff0000; border: 1px solid #ff0000; }
|
|
QSlider#crossfader::groove:horizontal {
|
|
border: 1px solid #777;
|
|
height: 16px;
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #00ffff, stop:0.5 #111, stop:1 #ff00ff);
|
|
border-radius: 8px;
|
|
}
|
|
QSlider#crossfader::handle:horizontal {
|
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #eee, stop:1 #888);
|
|
border: 2px solid #fff;
|
|
width: 32px;
|
|
height: 36px;
|
|
margin: -11px 0;
|
|
border-radius: 6px;
|
|
}
|
|
QSlider#crossfader::handle:horizontal:hover {
|
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #fff, stop:1 #aaa);
|
|
border-color: #00ff00;
|
|
}
|
|
"""
|
|
|
|
# --- WORKERS ---
|
|
|
|
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:
|
|
response = requests.get(self.url, stream=True, timeout=30)
|
|
if response.status_code != 200:
|
|
self.finished.emit(self.filepath, False)
|
|
return
|
|
|
|
total_size = int(response.headers.get('content-length', 0))
|
|
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:
|
|
self.progress.emit(int((downloaded / total_size) * 100))
|
|
|
|
self.finished.emit(self.filepath, True)
|
|
except Exception:
|
|
self.finished.emit(self.filepath, False)
|
|
|
|
class LibraryScannerThread(QThread):
|
|
files_found = pyqtSignal(list)
|
|
def __init__(self, lib_path):
|
|
super().__init__()
|
|
self.lib_path = lib_path
|
|
def run(self):
|
|
files = []
|
|
if self.lib_path.exists():
|
|
for f in self.lib_path.rglob('*'):
|
|
if f.suffix.lower() in ['.mp3', '.wav', '.ogg', '.m4a', '.flac']:
|
|
files.append(f)
|
|
files.sort(key=lambda x: x.name)
|
|
self.files_found.emit(files)
|
|
|
|
class ServerLibraryFetcher(QThread):
|
|
finished = pyqtSignal(list, str, bool)
|
|
|
|
def __init__(self, url):
|
|
super().__init__()
|
|
self.url = url
|
|
|
|
def run(self):
|
|
try:
|
|
response = requests.get(self.url, timeout=5)
|
|
if response.status_code == 200:
|
|
self.finished.emit(response.json(), "", True)
|
|
else:
|
|
self.finished.emit([], f"Server error: {response.status_code}", False)
|
|
except Exception as e:
|
|
self.finished.emit([], str(e), False)
|
|
|
|
class YTSearchWorker(QProcess):
|
|
results_ready = pyqtSignal(list)
|
|
error_occurred = pyqtSignal(str)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.output_buffer = b""
|
|
self.readyReadStandardOutput.connect(self.handle_output)
|
|
self.finished.connect(self.handle_finished)
|
|
|
|
def search(self, query):
|
|
self.output_buffer = b""
|
|
print(f"[DEBUG] Searching for: {query}")
|
|
cmd = sys.executable
|
|
args = [
|
|
"-m", "yt_dlp",
|
|
f"ytsearch5:{query}",
|
|
"--dump-json",
|
|
"--flat-playlist",
|
|
"--quiet",
|
|
"--no-warnings",
|
|
"--compat-options", "no-youtube-unavailable-videos"
|
|
]
|
|
self.start(cmd, args)
|
|
|
|
def handle_output(self):
|
|
self.output_buffer += self.readAllStandardOutput().data()
|
|
|
|
def handle_finished(self):
|
|
try:
|
|
results = []
|
|
decoded = self.output_buffer.decode('utf-8', errors='ignore').strip()
|
|
for line in decoded.split('\n'):
|
|
if line:
|
|
try:
|
|
results.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
if results:
|
|
self.results_ready.emit(results)
|
|
else:
|
|
self.error_occurred.emit("No results found or network error.")
|
|
except Exception as e:
|
|
self.error_occurred.emit(str(e))
|
|
|
|
class YTDownloadWorker(QProcess):
|
|
download_finished = pyqtSignal(str)
|
|
error_occurred = pyqtSignal(str)
|
|
download_progress = pyqtSignal(float) # Progress percentage (0-100)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.final_filename = ""
|
|
self.error_log = ""
|
|
self.readyReadStandardOutput.connect(self.handle_output)
|
|
self.readyReadStandardError.connect(self.handle_error)
|
|
self.finished.connect(self.handle_finished)
|
|
|
|
def download(self, url, dest, audio_format="mp3"):
|
|
# 1. Ensure Dest exists
|
|
if not os.path.exists(dest):
|
|
try:
|
|
os.makedirs(dest)
|
|
print(f"[DEBUG] Created directory: {dest}")
|
|
except Exception as e:
|
|
self.error_occurred.emit(f"Could not create folder: {e}")
|
|
return
|
|
|
|
# 2. Check FFmpeg (only needed for MP3 conversion)
|
|
if audio_format == "mp3" and not shutil.which("ffmpeg"):
|
|
self.error_occurred.emit("CRITICAL ERROR: FFmpeg is missing.\nRun 'sudo apt install ffmpeg' in terminal.")
|
|
return
|
|
|
|
self.final_filename = ""
|
|
self.error_log = ""
|
|
print(f"[DEBUG] Starting download: {url} -> {dest} (format: {audio_format})")
|
|
|
|
cmd = sys.executable
|
|
out_tmpl = os.path.join(dest, '%(title)s.%(ext)s')
|
|
|
|
# Build args based on format choice
|
|
args = ["-m", "yt_dlp"]
|
|
|
|
if audio_format == "mp3":
|
|
# MP3: Convert to MP3 (slower, universal)
|
|
args.extend([
|
|
"-f", "bestaudio/best",
|
|
"-x", "--audio-format", "mp3",
|
|
"--audio-quality", "192K",
|
|
])
|
|
else:
|
|
# Best Quality: Download original audio (faster, better quality)
|
|
args.extend([
|
|
"-f", "bestaudio[ext=m4a]/bestaudio", # Prefer m4a, fallback to best
|
|
])
|
|
|
|
# Common args
|
|
args.extend([
|
|
"-o", out_tmpl,
|
|
"--no-playlist",
|
|
"--newline",
|
|
"--no-warnings",
|
|
"--progress",
|
|
"--print", "after_move:filepath",
|
|
url
|
|
])
|
|
|
|
self.start(cmd, args)
|
|
|
|
def handle_output(self):
|
|
chunks = self.readAllStandardOutput().data().decode('utf-8', errors='ignore').splitlines()
|
|
for chunk in chunks:
|
|
line = chunk.strip()
|
|
if line:
|
|
# Progress parsing from stdout (newline mode)
|
|
if '[download]' in line and '%' in line:
|
|
try:
|
|
parts = line.split()
|
|
for part in parts:
|
|
if '%' in part:
|
|
p_str = part.replace('%', '')
|
|
self.download_progress.emit(float(p_str))
|
|
break
|
|
except: pass
|
|
# yt-dlp prints the final filepath via --print after_move:filepath
|
|
# Store it unconditionally — the file may not exist yet if FFmpeg
|
|
# post-processing is still running, so DON'T gate on os.path.exists here.
|
|
elif os.path.isabs(line) or (os.path.sep in line and any(
|
|
line.endswith(ext) for ext in ('.mp3', '.m4a', '.opus', '.ogg', '.wav', '.flac'))):
|
|
self.final_filename = line
|
|
print(f"[DEBUG] Captured output path: {line}")
|
|
|
|
def handle_error(self):
|
|
err_data = self.readAllStandardError().data().decode('utf-8', errors='ignore').strip()
|
|
if err_data:
|
|
# Only log actual errors
|
|
if "error" in err_data.lower():
|
|
print(f"[YT-DLP ERR] {err_data}")
|
|
self.error_log += err_data + "\n"
|
|
|
|
def handle_finished(self):
|
|
if self.exitCode() == 0 and self.final_filename:
|
|
# Poll for the file to fully appear on disk (replaces the unreliable 0.5s sleep).
|
|
# yt-dlp moves the file after FFmpeg post-processing finishes, so the file
|
|
# may take a moment to be visible. We wait up to 10 seconds.
|
|
deadline = time.time() + 10.0
|
|
while time.time() < deadline:
|
|
if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0:
|
|
break
|
|
time.sleep(0.1)
|
|
|
|
if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0:
|
|
print(f"[DEBUG] Download complete: {self.final_filename} ({os.path.getsize(self.final_filename)} bytes)")
|
|
self.download_finished.emit(self.final_filename)
|
|
else:
|
|
self.error_occurred.emit(f"Download finished but file missing or empty:\n{self.final_filename}")
|
|
elif self.exitCode() == 0 and not self.final_filename:
|
|
self.error_occurred.emit("Download finished but could not determine output filename.\nCheck the download folder manually.")
|
|
else:
|
|
self.error_occurred.emit(f"Download process failed.\n{self.error_log}")
|
|
|
|
class SettingsDialog(QDialog):
|
|
def __init__(self, settings_data, parent=None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Settings")
|
|
self.resize(650, 650)
|
|
self.setStyleSheet("background-color: #111; color: #fff;")
|
|
|
|
# Store all settings
|
|
self.shortcuts = settings_data.get("shortcuts", {}).copy()
|
|
self.audio_settings = settings_data.get("audio", {}).copy()
|
|
self.ui_settings = settings_data.get("ui", {}).copy()
|
|
self.library_settings = settings_data.get("library", {}).copy()
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
# Create tab widget
|
|
self.tabs = QTabWidget()
|
|
self.tabs.setStyleSheet("""
|
|
QTabWidget::pane { border: 1px solid #333; background: #0a0a0a; }
|
|
QTabBar::tab { background: #222; color: #888; padding: 8px 16px; margin: 2px; }
|
|
QTabBar::tab:selected { background: #00ffff; color: #000; font-weight: bold; }
|
|
QTabBar::tab:hover { background: #333; color: #fff; }
|
|
""")
|
|
|
|
# Tab 1: Keyboard Shortcuts
|
|
self.shortcuts_tab = self.create_shortcuts_tab()
|
|
self.tabs.addTab(self.shortcuts_tab, "Keyboard")
|
|
|
|
# Tab 2: Audio & Recording
|
|
self.audio_tab = self.create_audio_tab()
|
|
self.tabs.addTab(self.audio_tab, "Audio")
|
|
|
|
# Tab 3: UI Preferences
|
|
self.ui_tab = self.create_ui_tab()
|
|
self.tabs.addTab(self.ui_tab, "UI")
|
|
|
|
# Tab 4: Library
|
|
self.library_tab = self.create_library_tab()
|
|
self.tabs.addTab(self.library_tab, "Library")
|
|
|
|
layout.addWidget(self.tabs)
|
|
|
|
# Buttons
|
|
btn_layout = QHBoxLayout()
|
|
self.save_btn = QPushButton("Save All")
|
|
self.save_btn.clicked.connect(self.accept)
|
|
self.cancel_btn = QPushButton("Cancel")
|
|
self.cancel_btn.clicked.connect(self.reject)
|
|
btn_layout.addWidget(self.save_btn)
|
|
btn_layout.addWidget(self.cancel_btn)
|
|
layout.addLayout(btn_layout)
|
|
|
|
def create_shortcuts_tab(self):
|
|
widget = QWidget()
|
|
layout = QVBoxLayout(widget)
|
|
|
|
self.shortcuts_table = QTableWidget(len(self.shortcuts), 2)
|
|
self.shortcuts_table.setHorizontalHeaderLabels(["Action", "Key"])
|
|
self.shortcuts_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
self.shortcuts_table.setStyleSheet("background-color: #000; border: 1px solid #333;")
|
|
self.shortcuts_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
self.shortcuts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
self.shortcuts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
|
|
actions = sorted(self.shortcuts.keys())
|
|
for row, action in enumerate(actions):
|
|
self.shortcuts_table.setItem(row, 0, QTableWidgetItem(action))
|
|
self.shortcuts_table.setItem(row, 1, QTableWidgetItem(self.shortcuts[action]))
|
|
|
|
layout.addWidget(self.shortcuts_table)
|
|
|
|
rebind_btn = QPushButton("Rebind Selected Shortcut")
|
|
rebind_btn.clicked.connect(self.rebind_selected)
|
|
layout.addWidget(rebind_btn)
|
|
|
|
return widget
|
|
|
|
def create_audio_tab(self):
|
|
widget = QWidget()
|
|
layout = QVBoxLayout(widget)
|
|
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
|
|
# Streaming section
|
|
stream_group = QLabel("Live Streaming")
|
|
stream_group.setStyleSheet("font-size: 14px; font-weight: bold; color: #00ffff; margin-top: 10px;")
|
|
layout.addWidget(stream_group)
|
|
|
|
stream_url_label = QLabel("Stream Server URL:")
|
|
self.stream_url_input = QLineEdit()
|
|
self.stream_url_input.setPlaceholderText("http://YOUR_SERVER_IP:8080/api/stream")
|
|
current_stream_url = self.audio_settings.get("stream_server_url", "http://localhost:8080/api/stream")
|
|
self.stream_url_input.setText(current_stream_url)
|
|
self.stream_url_input.setStyleSheet("""
|
|
QLineEdit {
|
|
background: #1a1a1a;
|
|
border: 1px solid #333;
|
|
padding: 8px;
|
|
color: #fff;
|
|
border-radius: 4px;
|
|
}
|
|
""")
|
|
|
|
layout.addWidget(stream_url_label)
|
|
layout.addWidget(self.stream_url_input)
|
|
layout.addSpacing(20)
|
|
|
|
# Recording section
|
|
rec_group = QLabel("Recording")
|
|
rec_group.setStyleSheet("font-size: 14px; font-weight: bold; color: #ff00ff; margin-top: 10px;")
|
|
layout.addWidget(rec_group)
|
|
|
|
# Sample rate
|
|
rate_label = QLabel("Recording Sample Rate:")
|
|
self.sample_rate_combo = QComboBox()
|
|
self.sample_rate_combo.addItem("44.1 kHz", 44100)
|
|
self.sample_rate_combo.addItem("48 kHz (Recommended)", 48000)
|
|
current_rate = self.audio_settings.get("recording_sample_rate", 48000)
|
|
self.sample_rate_combo.setCurrentIndex(0 if current_rate == 44100 else 1)
|
|
|
|
# Format
|
|
format_label = QLabel("Recording Format:")
|
|
self.format_combo = QComboBox()
|
|
self.format_combo.addItem("WAV (Lossless)", "wav")
|
|
self.format_combo.addItem("MP3 (Compressed)", "mp3")
|
|
current_format = self.audio_settings.get("recording_format", "wav")
|
|
self.format_combo.setCurrentIndex(0 if current_format == "wav" else 1)
|
|
|
|
layout.addWidget(rate_label)
|
|
layout.addWidget(self.sample_rate_combo)
|
|
layout.addSpacing(10)
|
|
layout.addWidget(format_label)
|
|
layout.addWidget(self.format_combo)
|
|
layout.addStretch()
|
|
|
|
return widget
|
|
|
|
def create_ui_tab(self):
|
|
widget = QWidget()
|
|
layout = QVBoxLayout(widget)
|
|
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
|
|
# Neon mode default
|
|
neon_label = QLabel("Default Neon Edge Mode:")
|
|
self.neon_combo = QComboBox()
|
|
self.neon_combo.addItem("Off", 0)
|
|
self.neon_combo.addItem("Blue (Cyan)", 1)
|
|
self.neon_combo.addItem("Purple (Magenta)", 2)
|
|
current_neon = self.ui_settings.get("neon_mode", 0)
|
|
self.neon_combo.setCurrentIndex(current_neon)
|
|
|
|
layout.addWidget(neon_label)
|
|
layout.addWidget(self.neon_combo)
|
|
layout.addStretch()
|
|
|
|
return widget
|
|
|
|
def create_library_tab(self):
|
|
widget = QWidget()
|
|
layout = QVBoxLayout(widget)
|
|
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
|
|
# Auto-scan
|
|
self.auto_scan_check = QCheckBox("Auto-scan library on startup")
|
|
self.auto_scan_check.setChecked(self.library_settings.get("auto_scan", True))
|
|
|
|
# YouTube default format
|
|
yt_label = QLabel("YouTube Download Default Format:")
|
|
self.yt_format_combo = QComboBox()
|
|
self.yt_format_combo.addItem("MP3 (Universal)", "mp3")
|
|
self.yt_format_combo.addItem("Best Quality (Faster)", "best")
|
|
current_yt = self.library_settings.get("yt_default_format", "mp3")
|
|
self.yt_format_combo.setCurrentIndex(0 if current_yt == "mp3" else 1)
|
|
|
|
layout.addWidget(self.auto_scan_check)
|
|
layout.addSpacing(10)
|
|
layout.addWidget(yt_label)
|
|
layout.addWidget(self.yt_format_combo)
|
|
layout.addStretch()
|
|
|
|
return widget
|
|
|
|
def rebind_selected(self):
|
|
row = self.shortcuts_table.currentRow()
|
|
if row < 0:
|
|
QMessageBox.warning(self, "No Selection", "Please select an action to rebind.")
|
|
return
|
|
|
|
action = self.shortcuts_table.item(row, 0).text()
|
|
|
|
from PyQt6.QtWidgets import QInputDialog
|
|
new_key, ok = QInputDialog.getText(self, "Rebind Key", f"Enter new key sequence for {action}:", text=self.shortcuts[action])
|
|
if ok and new_key:
|
|
self.shortcuts[action] = new_key
|
|
self.shortcuts_table.item(row, 1).setText(new_key)
|
|
|
|
def get_all_settings(self):
|
|
"""Return all settings as a dictionary"""
|
|
return {
|
|
"shortcuts": self.shortcuts,
|
|
"audio": {
|
|
"recording_sample_rate": self.sample_rate_combo.currentData(),
|
|
"recording_format": self.format_combo.currentData(),
|
|
"stream_server_url": self.stream_url_input.text(),
|
|
},
|
|
"ui": {
|
|
"neon_mode": self.neon_combo.currentData(),
|
|
},
|
|
"library": {
|
|
"auto_scan": self.auto_scan_check.isChecked(),
|
|
"yt_default_format": self.yt_format_combo.currentData(),
|
|
}
|
|
}
|
|
|
|
|
|
class YTResultDialog(QDialog):
|
|
def __init__(self, results, parent=None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("YouTube Pro Search")
|
|
self.resize(600, 450)
|
|
self.setStyleSheet("""
|
|
QDialog { background-color: #0a0a0a; border: 2px solid #cc0000; }
|
|
QListWidget { background-color: #000; color: #0f0; border: 1px solid #333; font-family: 'Courier New'; font-size: 13px; }
|
|
QListWidget::item { padding: 10px; border-bottom: 1px solid #111; }
|
|
QListWidget::item:selected { background-color: #222; color: #fff; border: 1px solid #cc0000; }
|
|
QLabel { color: #fff; font-weight: bold; font-size: 16px; margin-bottom: 10px; }
|
|
QPushButton { background-color: #cc0000; color: white; border: none; padding: 12px; font-weight: bold; border-radius: 5px; }
|
|
QPushButton:hover { background-color: #ff0000; }
|
|
""")
|
|
|
|
layout = QVBoxLayout(self)
|
|
header = QLabel("YouTube Search Results")
|
|
layout.addWidget(header)
|
|
|
|
self.list_widget = QListWidget()
|
|
layout.addWidget(self.list_widget)
|
|
|
|
for vid in results:
|
|
duration_sec = vid.get('duration', 0)
|
|
if not duration_sec: duration_sec = 0
|
|
m, s = divmod(int(duration_sec), 60)
|
|
title_text = vid.get('title', 'Unknown Title')
|
|
channel = vid.get('uploader', 'Unknown Artist')
|
|
|
|
item = QListWidgetItem(f"{title_text}\n [{m:02}:{s:02}] - {channel}")
|
|
item.setData(Qt.ItemDataRole.UserRole, vid.get('url'))
|
|
self.list_widget.addItem(item)
|
|
|
|
self.list_widget.itemDoubleClicked.connect(self.accept)
|
|
|
|
btn_layout = QHBoxLayout()
|
|
self.cancel_btn = QPushButton("CANCEL")
|
|
self.cancel_btn.setStyleSheet("background-color: #333; color: #888; border-radius: 5px;")
|
|
self.cancel_btn.clicked.connect(self.reject)
|
|
|
|
btn_hl = QPushButton("DOWNLOAD & IMPORT")
|
|
btn_hl.clicked.connect(self.accept)
|
|
|
|
btn_layout.addWidget(self.cancel_btn)
|
|
btn_layout.addWidget(btn_hl)
|
|
layout.addLayout(btn_layout)
|
|
def get_selected_url(self):
|
|
i = self.list_widget.currentItem()
|
|
return i.data(Qt.ItemDataRole.UserRole) if i else None
|
|
|
|
class RecordingWorker(QProcess):
|
|
"""Records system audio output using FFmpeg"""
|
|
recording_started = pyqtSignal()
|
|
recording_error = pyqtSignal(str)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.output_file = ""
|
|
self.readyReadStandardError.connect(self.handle_error)
|
|
|
|
def start_recording(self, output_path):
|
|
"""Start recording system audio to file"""
|
|
self.output_file = output_path
|
|
|
|
# Check if FFmpeg is available
|
|
if not shutil.which("ffmpeg"):
|
|
self.recording_error.emit("FFmpeg not found. Install with: sudo apt install ffmpeg")
|
|
return False
|
|
|
|
print(f"[RECORDING] Starting: {output_path}")
|
|
|
|
# FFmpeg command to record PulseAudio output with high quality
|
|
# IMPORTANT: Use .monitor to capture OUTPUT (what you hear), not INPUT (microphone)
|
|
# -f pulse: use PulseAudio
|
|
# -i default.monitor: capture system audio OUTPUT (not microphone)
|
|
# -ac 2: stereo
|
|
# -ar 48000: 48kHz sample rate (higher quality than 44.1kHz)
|
|
# -acodec pcm_s16le: uncompressed 16-bit PCM (lossless)
|
|
# -sample_fmt s16: 16-bit samples
|
|
args = [
|
|
"-f", "pulse",
|
|
"-i", "default.monitor", # .monitor captures OUTPUT, not microphone!
|
|
"-ac", "2",
|
|
"-ar", "48000", # Higher sample rate for better quality
|
|
"-acodec", "pcm_s16le", # Lossless PCM codec
|
|
"-sample_fmt", "s16",
|
|
"-y", # Overwrite if exists
|
|
output_path
|
|
]
|
|
|
|
self.start("ffmpeg", args)
|
|
self.recording_started.emit()
|
|
return True
|
|
|
|
def stop_recording(self):
|
|
"""Stop the recording"""
|
|
if self.state() == QProcess.ProcessState.Running:
|
|
print("[RECORDING] Stopping...")
|
|
# Send 'q' to FFmpeg to gracefully stop
|
|
self.write(b"q")
|
|
self.waitForFinished(3000)
|
|
if self.state() == QProcess.ProcessState.Running:
|
|
self.kill()
|
|
|
|
def handle_error(self):
|
|
"""Handle FFmpeg stderr (which includes progress info)"""
|
|
err = self.readAllStandardError().data().decode('utf-8', errors='ignore').strip()
|
|
if err and "error" in err.lower():
|
|
print(f"[RECORDING ERROR] {err}")
|
|
|
|
class StreamingWorker(QThread):
|
|
"""Streams system audio output to a server using Socket.IO Chunks"""
|
|
streaming_started = pyqtSignal()
|
|
streaming_error = pyqtSignal(str)
|
|
listener_count = pyqtSignal(int)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.sio = socketio.Client()
|
|
self.stream_url = ""
|
|
self.is_running = False
|
|
self.ffmpeg_proc = None
|
|
|
|
# Socket.IO event handlers
|
|
self.sio.on('connect', self.on_connect)
|
|
self.sio.on('disconnect', self.on_disconnect)
|
|
self.sio.on('listener_count', self.on_listener_count)
|
|
self.sio.on('connect_error', self.on_connect_error)
|
|
|
|
def on_connect(self):
|
|
print("[SOCKET] Connected to DJ server")
|
|
self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'})
|
|
self.streaming_started.emit()
|
|
|
|
def on_disconnect(self):
|
|
print("[SOCKET] Disconnected from DJ server")
|
|
|
|
def on_connect_error(self, data):
|
|
self.streaming_error.emit(f"Connection error: {data}")
|
|
|
|
def on_listener_count(self, data):
|
|
self.listener_count.emit(data.get('count', 0))
|
|
|
|
def run(self):
|
|
try:
|
|
# Connect to socket
|
|
self.sio.connect(self.stream_url)
|
|
|
|
# Start FFmpeg to capture audio and output to pipe
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-hide_banner",
|
|
"-loglevel", "error",
|
|
"-f", "pulse",
|
|
"-i", "default.monitor",
|
|
"-ac", "2",
|
|
"-ar", "44100",
|
|
"-f", "mp3",
|
|
"-b:a", "128k",
|
|
"-af", "aresample=async=1",
|
|
"pipe:1"
|
|
]
|
|
self.ffmpeg_proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192)
|
|
|
|
while self.is_running and self.ffmpeg_proc.poll() is None:
|
|
chunk = self.ffmpeg_proc.stdout.read(8192)
|
|
if not chunk: break
|
|
if self.sio.connected:
|
|
self.sio.emit('audio_chunk', chunk)
|
|
|
|
except Exception as e:
|
|
self.streaming_error.emit(f"Streaming thread error: {e}")
|
|
finally:
|
|
self.stop_streaming()
|
|
|
|
def start_streaming(self, base_url, bitrate=128):
|
|
self.stream_url = base_url
|
|
self.is_running = True
|
|
self.start()
|
|
return True
|
|
|
|
def stop_streaming(self):
|
|
self.is_running = False
|
|
if self.ffmpeg_proc:
|
|
try: self.ffmpeg_proc.terminate()
|
|
except: pass
|
|
self.ffmpeg_proc = None
|
|
if self.sio.connected:
|
|
self.sio.emit('stop_broadcast')
|
|
time.sleep(0.2)
|
|
self.sio.disconnect()
|
|
|
|
# --- WIDGETS ---
|
|
|
|
class GlowFrame(QWidget):
|
|
"""Custom widget that paints a neon glow effect around the edges"""
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) # Don't block mouse events
|
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
self.glow_color = QColor("#0ff")
|
|
self.glow_enabled = False
|
|
|
|
def set_glow(self, enabled, color="#0ff"):
|
|
self.glow_enabled = enabled
|
|
self.glow_color = QColor(color)
|
|
self.update()
|
|
|
|
def paintEvent(self, event):
|
|
if not self.glow_enabled:
|
|
return
|
|
|
|
painter = QPainter(self)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
|
|
rect = self.rect()
|
|
glow_width = 80 # Wider glow for more intensity
|
|
|
|
# Draw glow from each edge using linear gradients
|
|
from PyQt6.QtGui import QLinearGradient
|
|
|
|
# Top edge glow
|
|
top_gradient = QLinearGradient(0, 0, 0, glow_width)
|
|
for i in range(6):
|
|
pos = i / 5.0
|
|
alpha = int(255 * (1 - pos)) # Full opacity at edge
|
|
color = QColor(self.glow_color)
|
|
color.setAlpha(alpha)
|
|
top_gradient.setColorAt(pos, color)
|
|
painter.fillRect(0, 0, rect.width(), glow_width, top_gradient)
|
|
|
|
# Bottom edge glow
|
|
bottom_gradient = QLinearGradient(0, rect.height() - glow_width, 0, rect.height())
|
|
for i in range(6):
|
|
pos = i / 5.0
|
|
alpha = int(255 * pos)
|
|
color = QColor(self.glow_color)
|
|
color.setAlpha(alpha)
|
|
bottom_gradient.setColorAt(pos, color)
|
|
painter.fillRect(0, rect.height() - glow_width, rect.width(), glow_width, bottom_gradient)
|
|
|
|
# Left edge glow
|
|
left_gradient = QLinearGradient(0, 0, glow_width, 0)
|
|
for i in range(6):
|
|
pos = i / 5.0
|
|
alpha = int(255 * (1 - pos))
|
|
color = QColor(self.glow_color)
|
|
color.setAlpha(alpha)
|
|
left_gradient.setColorAt(pos, color)
|
|
painter.fillRect(0, 0, glow_width, rect.height(), left_gradient)
|
|
|
|
# Right edge glow
|
|
right_gradient = QLinearGradient(rect.width() - glow_width, 0, rect.width(), 0)
|
|
for i in range(6):
|
|
pos = i / 5.0
|
|
alpha = int(255 * pos)
|
|
color = QColor(self.glow_color)
|
|
color.setAlpha(alpha)
|
|
right_gradient.setColorAt(pos, color)
|
|
painter.fillRect(rect.width() - glow_width, 0, glow_width, rect.height(), right_gradient)
|
|
|
|
class VinylWidget(QWidget):
|
|
def __init__(self, color_hex, parent=None):
|
|
super().__init__(parent)
|
|
self.setMinimumSize(120, 120)
|
|
self.angle = 0
|
|
self.speed = 1.0
|
|
self.is_spinning = False
|
|
self.color = QColor(color_hex)
|
|
|
|
# Initialize drawing resources
|
|
self.brush_disk = QBrush(QColor("#111"))
|
|
self.pen_disk = QPen(QColor("#000"), 2)
|
|
self.brush_label = QBrush(self.color)
|
|
self.brush_white = QBrush(Qt.GlobalColor.white)
|
|
self.center = QPointF(0, 0)
|
|
self.radius = 0
|
|
|
|
self.timer = QTimer(self)
|
|
self.timer.timeout.connect(self.rotate)
|
|
|
|
def resizeEvent(self, event):
|
|
w, h = self.width(), self.height()
|
|
self.center = QPointF(w / 2, h / 2)
|
|
self.radius = min(w, h) / 2 - 5
|
|
super().resizeEvent(event)
|
|
|
|
def start_spin(self):
|
|
if not self.is_spinning:
|
|
self.is_spinning = True
|
|
self.timer.start(ANIMATION_INTERVAL)
|
|
|
|
def stop_spin(self):
|
|
self.is_spinning = False
|
|
self.timer.stop()
|
|
|
|
def set_speed(self, rate):
|
|
self.speed = rate
|
|
|
|
def rotate(self):
|
|
self.angle = (self.angle + 3.0 * self.speed) % 360
|
|
self.update()
|
|
|
|
def paintEvent(self, event):
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
p.translate(self.center)
|
|
p.rotate(self.angle)
|
|
|
|
# Draw vinyl disk
|
|
p.setBrush(self.brush_disk)
|
|
p.setPen(self.pen_disk)
|
|
p.drawEllipse(QPointF(0, 0), self.radius, self.radius)
|
|
|
|
# Draw grooves
|
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
|
p.setPen(QPen(QColor("#222"), 1))
|
|
p.drawEllipse(QPointF(0, 0), self.radius * 0.8, self.radius * 0.8)
|
|
p.drawEllipse(QPointF(0, 0), self.radius * 0.6, self.radius * 0.6)
|
|
|
|
# Draw center label
|
|
p.setBrush(self.brush_label)
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.drawEllipse(QPointF(0, 0), self.radius * 0.35, self.radius * 0.35)
|
|
|
|
# Draw position marker
|
|
p.setBrush(self.brush_white)
|
|
p.drawRect(QRectF(-2, -self.radius * 0.35, 4, 12))
|
|
|
|
class WaveformWidget(QWidget):
|
|
seekRequested = pyqtSignal(int)
|
|
|
|
def __init__(self, color_hex, parent=None):
|
|
super().__init__(parent)
|
|
self.color = QColor(color_hex)
|
|
self.setMinimumHeight(60)
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
|
|
self.duration = 1
|
|
self.position = 0
|
|
self.wave_data = []
|
|
self.loop_active = False
|
|
self.loop_start = 0
|
|
self.loop_end = 0
|
|
self.last_seek_time = 0
|
|
|
|
# Initialize drawing resources
|
|
self.brush_active = QBrush(self.color)
|
|
self.brush_inactive = QBrush(QColor("#444"))
|
|
self.pen_white = QPen(QColor("#fff"), 2)
|
|
self.loop_brush = QBrush(QColor(255, 165, 0, 100))
|
|
self.loop_pen = QPen(QColor("#ffa500"), 2)
|
|
|
|
def generate_wave(self, file_path):
|
|
random.seed(str(file_path))
|
|
self.wave_data = [max(0.1, random.random()**2) for _ in range(250)]
|
|
self.update()
|
|
|
|
def set_duration(self, d):
|
|
self.duration = max(1, d)
|
|
self.update()
|
|
|
|
def set_position(self, p):
|
|
self.position = p
|
|
self.update()
|
|
|
|
def set_loop_region(self, active, start, end):
|
|
self.loop_active = active
|
|
self.loop_start = start
|
|
self.loop_end = end
|
|
self.update()
|
|
|
|
def mousePressEvent(self, e):
|
|
if time.time() - self.last_seek_time > 0.1:
|
|
seek_pos = int((e.position().x() / self.width()) * self.duration)
|
|
self.seekRequested.emit(seek_pos)
|
|
self.last_seek_time = time.time()
|
|
|
|
def mouseMoveEvent(self, e):
|
|
if time.time() - self.last_seek_time > 0.1:
|
|
seek_pos = int((e.position().x() / self.width()) * self.duration)
|
|
self.seekRequested.emit(seek_pos)
|
|
self.last_seek_time = time.time()
|
|
|
|
def paintEvent(self, event):
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
w, h = self.width(), self.height()
|
|
p.fillRect(0, 0, w, h, QColor("#111"))
|
|
|
|
if not self.wave_data:
|
|
p.setPen(self.pen_white)
|
|
p.drawLine(0, int(h / 2), w, int(h / 2))
|
|
return
|
|
|
|
bar_width = w / len(self.wave_data)
|
|
play_x = (self.position / self.duration) * w
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
|
|
# Draw waveform bars
|
|
for i, val in enumerate(self.wave_data):
|
|
brush = self.brush_active if i * bar_width < play_x else self.brush_inactive
|
|
p.setBrush(brush)
|
|
bar_height = val * h * 0.9
|
|
p.drawRect(QRectF(i * bar_width, (h - bar_height) / 2, bar_width, bar_height))
|
|
|
|
# Draw loop region
|
|
if self.loop_active:
|
|
loop_x = (self.loop_start / self.duration) * w
|
|
loop_width = ((self.loop_end - self.loop_start) / self.duration) * w
|
|
p.setBrush(self.loop_brush)
|
|
p.drawRect(QRectF(loop_x, 0, loop_width, h))
|
|
p.setPen(self.loop_pen)
|
|
p.drawLine(int(loop_x), 0, int(loop_x), h)
|
|
p.drawLine(int(loop_x + loop_width), 0, int(loop_x + loop_width), h)
|
|
|
|
# Draw playhead
|
|
p.setPen(self.pen_white)
|
|
p.drawLine(int(play_x), 0, int(play_x), h)
|
|
|
|
class DeckWidget(QGroupBox):
|
|
def __init__(self, name, color_code, deck_id, parent=None):
|
|
super().__init__(name, parent)
|
|
self.setObjectName(name.replace(" ", "_"))
|
|
self.color_code = color_code
|
|
self.deck_id = deck_id
|
|
self.playback_mode = 0
|
|
self.loop_active = False
|
|
self.loop_start = 0
|
|
self.loop_end = 0
|
|
self.loop_btns = []
|
|
self.xf_vol = 100
|
|
|
|
self.loop_timer = QTimer(self)
|
|
self.loop_timer.setInterval(LOOP_CHECK_INTERVAL)
|
|
self.loop_timer.timeout.connect(self.check_loop)
|
|
|
|
self.audio_output = QAudioOutput()
|
|
self.player = QMediaPlayer()
|
|
self.player.setAudioOutput(self.audio_output)
|
|
self.player.positionChanged.connect(self.on_position_changed)
|
|
self.player.durationChanged.connect(self.on_duration_changed)
|
|
self.player.mediaStatusChanged.connect(self.check_queue)
|
|
self.real_duration = 0
|
|
|
|
self.setup_ui()
|
|
|
|
def setup_ui(self):
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(5)
|
|
|
|
# Top row: Vinyl and track info
|
|
r1 = QHBoxLayout()
|
|
self.vinyl = VinylWidget(self.color_code)
|
|
r1.addWidget(self.vinyl)
|
|
|
|
c1 = QVBoxLayout()
|
|
self.lbl_tr = QLabel("NO MEDIA")
|
|
self.lbl_tr.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.lbl_tr.setStyleSheet(
|
|
f"color: {self.color_code}; border: 1px solid {self.color_code}; "
|
|
f"background: #000; padding: 4px;"
|
|
)
|
|
self.lbl_tr.setWordWrap(True)
|
|
c1.addWidget(self.lbl_tr)
|
|
|
|
rt = QHBoxLayout()
|
|
self.lbl_cur = QLabel("00:00")
|
|
self.lbl_cur.setStyleSheet("color:#fff")
|
|
self.lbl_tot = QLabel("00:00")
|
|
self.lbl_tot.setStyleSheet("color:#fff")
|
|
rt.addWidget(self.lbl_cur)
|
|
rt.addStretch()
|
|
rt.addWidget(self.lbl_tot)
|
|
c1.addLayout(rt)
|
|
r1.addLayout(c1)
|
|
layout.addLayout(r1)
|
|
|
|
# Waveform
|
|
self.wave = WaveformWidget(self.color_code)
|
|
self.wave.seekRequested.connect(self.player.setPosition)
|
|
layout.addWidget(self.wave)
|
|
|
|
# Loop buttons
|
|
g = QGridLayout()
|
|
g.setSpacing(2)
|
|
loops = [("8", 8), ("4", 4), ("2", 2), ("1", 1), ("1/2", 0.5), ("1/4", 0.25), ("1/8", 0.125)]
|
|
for i, (label, beats) in enumerate(loops):
|
|
btn = QPushButton(label)
|
|
btn.setObjectName("btn_loop")
|
|
btn.setCheckable(True)
|
|
btn.setToolTip(f"Set loop to {beats} beat(s)")
|
|
btn.clicked.connect(lambda c, b=beats, o=btn: self.set_loop(b, o))
|
|
g.addWidget(btn, 0, i)
|
|
self.loop_btns.append(btn)
|
|
|
|
exit_btn = QPushButton("EXIT")
|
|
exit_btn.setObjectName("btn_loop_exit")
|
|
exit_btn.setToolTip("Clear active loop")
|
|
exit_btn.clicked.connect(self.clear_loop)
|
|
g.addWidget(exit_btn, 0, len(loops))
|
|
layout.addLayout(g)
|
|
|
|
# Playback controls
|
|
rc = QHBoxLayout()
|
|
bp = QPushButton("PLAY")
|
|
bp.setToolTip("Play track")
|
|
bp.clicked.connect(self.play)
|
|
|
|
bpa = QPushButton("PAUSE")
|
|
bpa.setToolTip("Pause playback")
|
|
bpa.clicked.connect(self.pause)
|
|
|
|
bs = QPushButton("STOP")
|
|
bs.setToolTip("Stop playback")
|
|
bs.clicked.connect(self.stop)
|
|
|
|
self.b_mode = QPushButton("MODE: CONT")
|
|
self.b_mode.setFixedWidth(100)
|
|
self.b_mode.setProperty("mode", "0")
|
|
self.b_mode.setToolTip("Cycle playback mode: Continuous / Loop 1 / Stop")
|
|
self.b_mode.clicked.connect(self.cycle_mode)
|
|
|
|
rc.addWidget(bp)
|
|
rc.addWidget(bpa)
|
|
rc.addWidget(bs)
|
|
rc.addSpacing(10)
|
|
rc.addWidget(self.b_mode)
|
|
layout.addLayout(rc)
|
|
|
|
# Pitch control
|
|
rp = QHBoxLayout()
|
|
self.sl_rate = QSlider(Qt.Orientation.Horizontal)
|
|
self.sl_rate.setRange(50, 150)
|
|
self.sl_rate.setValue(100)
|
|
self.sl_rate.setToolTip("Adjust playback speed / pitch")
|
|
self.sl_rate.valueChanged.connect(self.update_playback_rate)
|
|
|
|
br = QPushButton("R")
|
|
br.setToolTip("Reset pitch to 1.0x")
|
|
br.clicked.connect(lambda: self.sl_rate.setValue(100))
|
|
|
|
self.lbl_rate = QLabel("1.0x")
|
|
self.lbl_rate.setStyleSheet("color:#fff; font-weight:bold;")
|
|
|
|
rp.addWidget(QLabel("PITCH", styleSheet="color:#666"))
|
|
rp.addWidget(self.sl_rate)
|
|
rp.addWidget(br)
|
|
rp.addWidget(self.lbl_rate)
|
|
layout.addLayout(rp)
|
|
|
|
# Bottom section: Queue and EQ
|
|
bottom = QHBoxLayout()
|
|
|
|
# Queue widget
|
|
qc = QWidget()
|
|
ql = QVBoxLayout(qc)
|
|
ql.setContentsMargins(0, 0, 0, 0)
|
|
|
|
hq = QHBoxLayout()
|
|
hq.addWidget(QLabel(f"QUEUE {self.deck_id}", styleSheet="font-size:10px; color:#666"))
|
|
bd = QPushButton("X")
|
|
bd.setObjectName("btn_remove")
|
|
bd.setToolTip("Remove selected track from queue")
|
|
bd.clicked.connect(self.delete_selected)
|
|
hq.addStretch()
|
|
hq.addWidget(bd)
|
|
ql.addLayout(hq)
|
|
|
|
self.q_list = QListWidget()
|
|
self.q_list.setObjectName("queue_list")
|
|
self.q_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
|
|
self.q_list.setDefaultDropAction(Qt.DropAction.MoveAction)
|
|
self.q_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
self.q_list.itemDoubleClicked.connect(
|
|
lambda i: self.q_list.takeItem(self.q_list.row(i))
|
|
)
|
|
QShortcut(QKeySequence(Qt.Key.Key_Delete), self.q_list).activated.connect(self.delete_selected)
|
|
ql.addWidget(self.q_list)
|
|
|
|
# EQ sliders widget
|
|
sc = QWidget()
|
|
sl = QVBoxLayout(sc)
|
|
sl.setContentsMargins(0, 0, 0, 0)
|
|
row_s = QHBoxLayout()
|
|
|
|
def make_slider(prop, label, tooltip):
|
|
v = QVBoxLayout()
|
|
s = QSlider(Qt.Orientation.Vertical)
|
|
s.setRange(0, MAX_SLIDER_VALUE)
|
|
s.setValue(MAX_SLIDER_VALUE)
|
|
s.setProperty("eq", prop)
|
|
s.setToolTip(tooltip)
|
|
s.valueChanged.connect(self.recalc_vol)
|
|
l = QLabel(label)
|
|
l.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
l.setStyleSheet("font-size:8px; color:#aaa;")
|
|
v.addWidget(s, 1, Qt.AlignmentFlag.AlignHCenter)
|
|
v.addWidget(l)
|
|
row_s.addLayout(v)
|
|
return s
|
|
|
|
self.sl_vol = make_slider("vol", "LEV", "Volume level")
|
|
self.sl_hi = make_slider("high", "HI", "High frequencies (treble)")
|
|
self.sl_mid = make_slider("mid", "MID", "Mid frequencies")
|
|
self.sl_low = make_slider("low", "LO", "Low frequencies (bass)")
|
|
|
|
sl.addLayout(row_s)
|
|
|
|
if self.deck_id == "A":
|
|
bottom.addWidget(qc, 3)
|
|
bottom.addWidget(sc, 1)
|
|
else:
|
|
bottom.addWidget(sc, 1)
|
|
bottom.addWidget(qc, 3)
|
|
|
|
layout.addLayout(bottom, 1)
|
|
self.setLayout(layout)
|
|
|
|
def delete_selected(self):
|
|
for item in self.q_list.selectedItems():
|
|
self.q_list.takeItem(self.q_list.row(item))
|
|
|
|
def cycle_mode(self):
|
|
self.playback_mode = (self.playback_mode + 1) % 3
|
|
modes = {0: "CONT", 1: "LOOP 1", 2: "STOP"}
|
|
self.b_mode.setText(f"MODE: {modes[self.playback_mode]}")
|
|
self.b_mode.setProperty("mode", str(self.playback_mode))
|
|
self.b_mode.style().unpolish(self.b_mode)
|
|
self.b_mode.style().polish(self.b_mode)
|
|
|
|
def set_loop(self, beats, btn):
|
|
for x in self.loop_btns:
|
|
x.setChecked(x == btn)
|
|
|
|
if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState:
|
|
return
|
|
|
|
ms_per_beat = MS_PER_MINUTE / DEFAULT_BPM
|
|
self.loop_start = self.player.position()
|
|
self.loop_end = self.loop_start + int(ms_per_beat * beats)
|
|
self.loop_active = True
|
|
self.loop_timer.start()
|
|
self.wave.set_loop_region(True, self.loop_start, self.loop_end)
|
|
|
|
def clear_loop(self):
|
|
self.loop_active = False
|
|
self.loop_timer.stop()
|
|
self.wave.set_loop_region(False, 0, 0)
|
|
for btn in self.loop_btns:
|
|
btn.setChecked(False)
|
|
|
|
def check_loop(self):
|
|
if self.loop_active and self.player.position() >= self.loop_end:
|
|
self.player.setPosition(int(self.loop_start))
|
|
|
|
def load_track(self, path):
|
|
if not path:
|
|
return
|
|
|
|
p = Path(path)
|
|
if not p.exists():
|
|
print(f"[ERROR] Track path does not exist: {p}")
|
|
return
|
|
|
|
try:
|
|
self.player.setSource(QUrl.fromLocalFile(str(p.absolute())))
|
|
self.lbl_tr.setText(p.stem.upper())
|
|
self.vinyl.set_speed(0)
|
|
self.vinyl.angle = 0
|
|
self.vinyl.update()
|
|
self.wave.generate_wave(p)
|
|
# Find parent DJApp to show status
|
|
parent = self.window()
|
|
if hasattr(parent, 'status_label'):
|
|
parent.status_label.setText(f"Loaded: {p.name}")
|
|
|
|
# Use soundfile to get accurate duration (GStreamer/Qt6 can be wrong)
|
|
try:
|
|
info = sf.info(str(p.absolute()))
|
|
self.real_duration = int(info.duration * 1000)
|
|
print(f"[DEBUG] {self.deck_id} Real Duration: {self.real_duration}ms")
|
|
# Update UI immediately if possible, or wait for player durationChanged
|
|
self.wave.set_duration(self.real_duration)
|
|
except Exception as se:
|
|
print(f"[DEBUG] Could not get accurate duration with soundfile: {se}")
|
|
self.real_duration = 0
|
|
except Exception as e:
|
|
print(f"[ERROR] Failed to load track {p}: {e}")
|
|
self.lbl_tr.setText("LOAD ERROR")
|
|
|
|
def add_queue(self, path):
|
|
p = Path(path)
|
|
item = QListWidgetItem(p.name)
|
|
item.setData(Qt.ItemDataRole.UserRole, p)
|
|
self.q_list.addItem(item)
|
|
|
|
def check_queue(self, status):
|
|
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
|
# Check if this is a premature EndOfMedia (common in GStreamer with certain VBR MP3s)
|
|
if self.real_duration > 0 and self.player.position() < self.real_duration - 1000:
|
|
print(f"[DEBUG] {self.deck_id} Premature EndOfMedia detected. Position: {self.player.position()}, Expected: {self.real_duration}")
|
|
# Don't skip yet, maybe the user wants to seek back?
|
|
# Or we could try to play again, but usually GStreamer won't go further.
|
|
|
|
if self.playback_mode == 1:
|
|
# Loop 1 mode
|
|
self.player.setPosition(0)
|
|
self.play()
|
|
elif self.playback_mode == 0 and self.q_list.count() > 0:
|
|
# Continuous mode - load next from queue
|
|
next_item = self.q_list.takeItem(0)
|
|
self.load_track(next_item.data(Qt.ItemDataRole.UserRole))
|
|
self.play()
|
|
else:
|
|
# Stop mode or no queue items
|
|
self.stop()
|
|
|
|
def play(self):
|
|
self.player.play()
|
|
self.vinyl.start_spin()
|
|
|
|
def pause(self):
|
|
self.player.pause()
|
|
self.vinyl.stop_spin()
|
|
|
|
def stop(self):
|
|
self.player.stop()
|
|
self.vinyl.stop_spin()
|
|
self.vinyl.angle = 0
|
|
self.vinyl.update()
|
|
self.clear_loop()
|
|
|
|
def on_position_changed(self, pos):
|
|
self.wave.set_position(pos)
|
|
minutes = int(pos // MS_PER_MINUTE)
|
|
seconds = int((pos // 1000) % 60)
|
|
self.lbl_cur.setText(f"{minutes:02}:{seconds:02}")
|
|
|
|
def on_duration_changed(self, duration):
|
|
# Use our accurate duration if available, otherwise fallback to player's reported duration
|
|
final_duration = self.real_duration if self.real_duration > 0 else duration
|
|
self.wave.set_duration(final_duration)
|
|
minutes = int(final_duration // MS_PER_MINUTE)
|
|
seconds = int((final_duration // 1000) % 60)
|
|
self.lbl_tot.setText(f"{minutes:02}:{seconds:02}")
|
|
|
|
def update_playback_rate(self, value):
|
|
rate = value / 100.0
|
|
self.player.setPlaybackRate(rate)
|
|
self.lbl_rate.setText(f"{rate:.1f}x")
|
|
self.vinyl.set_speed(rate)
|
|
|
|
def set_xf_vol(self, volume):
|
|
self.xf_vol = volume
|
|
self.recalc_vol()
|
|
|
|
def recalc_vol(self):
|
|
eq_hi = self.sl_hi.value() / MAX_SLIDER_VALUE
|
|
eq_mid = self.sl_mid.value() / MAX_SLIDER_VALUE
|
|
eq_low = self.sl_low.value() / MAX_SLIDER_VALUE
|
|
eq_gain = eq_hi * eq_mid * eq_low
|
|
|
|
final = (self.xf_vol / 100.0) * (self.sl_vol.value() / MAX_SLIDER_VALUE) * eq_gain
|
|
self.audio_output.setVolume(final)
|
|
|
|
class DJApp(QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("TechDJ Pro - Neon Edition")
|
|
self.resize(1200, 950)
|
|
self.setStyleSheet(STYLESHEET)
|
|
|
|
# Set window icon
|
|
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dj_icon.png")
|
|
if os.path.exists(icon_path):
|
|
self.setWindowIcon(QIcon(icon_path))
|
|
|
|
self.neon_state = 0
|
|
|
|
# --- LOCAL FOLDER SETUP ---
|
|
# Creates a folder named 'dj_tracks' inside your project directory
|
|
self.lib_path = Path(os.getcwd()) / "music"
|
|
if not self.lib_path.exists():
|
|
try:
|
|
self.lib_path.mkdir(exist_ok=True)
|
|
print(f"[INIT] Created library folder: {self.lib_path}")
|
|
except Exception as e:
|
|
print(f"[ERROR] Could not create library folder: {e}")
|
|
|
|
# Create recordings folder
|
|
self.recordings_path = Path(os.getcwd()) / "recordings"
|
|
if not self.recordings_path.exists():
|
|
try:
|
|
self.recordings_path.mkdir(exist_ok=True)
|
|
print(f"[INIT] Created recordings folder: {self.recordings_path}")
|
|
except Exception as e:
|
|
print(f"[ERROR] Could not create recordings folder: {e}")
|
|
|
|
self.search_worker = YTSearchWorker()
|
|
self.search_worker.results_ready.connect(self.on_search_results)
|
|
self.search_worker.error_occurred.connect(self.on_error)
|
|
|
|
self.download_worker = YTDownloadWorker()
|
|
self.download_worker.download_finished.connect(self.on_download_complete)
|
|
self.download_worker.error_occurred.connect(self.on_error)
|
|
self.download_worker.download_progress.connect(self.update_download_progress)
|
|
|
|
# Recording setup
|
|
self.recording_worker = RecordingWorker()
|
|
self.recording_worker.recording_error.connect(self.on_recording_error)
|
|
self.is_recording = False
|
|
self.recording_start_time = 0
|
|
self.recording_timer = QTimer()
|
|
self.recording_timer.timeout.connect(self.update_recording_time)
|
|
|
|
# Streaming setup
|
|
self.streaming_worker = StreamingWorker()
|
|
self.streaming_worker.streaming_error.connect(self.on_streaming_error)
|
|
self.streaming_worker.listener_count.connect(self.update_listener_count)
|
|
self.is_streaming = False
|
|
|
|
# Server library state
|
|
self.server_url = "http://localhost:5000"
|
|
self.library_mode = "local" # "local" or "server"
|
|
self.server_library = []
|
|
self.local_library = []
|
|
self.cache_dir = Path.home() / ".techdj_cache"
|
|
self.cache_dir.mkdir(exist_ok=True)
|
|
self.download_threads = {}
|
|
|
|
self.init_ui()
|
|
self.setup_keyboard_shortcuts()
|
|
self.apply_ui_settings() # Apply saved UI settings
|
|
|
|
# Filtering debounce timer
|
|
self.filter_timer = QTimer()
|
|
self.filter_timer.setSingleShot(True)
|
|
self.filter_timer.timeout.connect(self.perform_filter)
|
|
|
|
self.load_library()
|
|
|
|
def init_ui(self):
|
|
main = QWidget()
|
|
main.setObjectName("Central") # Set objectName for neon border styling
|
|
self.setCentralWidget(main)
|
|
layout = QVBoxLayout(main)
|
|
layout.setContentsMargins(15, 15, 15, 15)
|
|
|
|
# Create glow overlay
|
|
self.glow_frame = GlowFrame(main)
|
|
self.glow_frame.setGeometry(main.rect())
|
|
self.glow_frame.raise_() # Bring to front
|
|
|
|
# Top bar: Neon toggle and YouTube search
|
|
h = QHBoxLayout()
|
|
self.neon_button = QPushButton("NEON EDGE: OFF")
|
|
self.neon_button.setObjectName("btn_neon")
|
|
self.neon_button.setFixedWidth(150)
|
|
self.neon_button.setToolTip("Toggle neon border effect")
|
|
self.neon_button.clicked.connect(self.toggle_neon)
|
|
|
|
self.yt_input = QLineEdit()
|
|
self.yt_input.setPlaceholderText("Search YouTube or Paste URL...")
|
|
self.yt_input.setToolTip("Search YouTube with keywords or paste a YouTube/YT Music URL to download directly")
|
|
self.yt_input.returnPressed.connect(self.search_youtube)
|
|
|
|
# Format selector dropdown
|
|
self.format_selector = QComboBox()
|
|
self.format_selector.addItem("MP3 (slower, universal)", "mp3")
|
|
self.format_selector.addItem("Best Quality (faster)", "best")
|
|
self.format_selector.setCurrentIndex(0) # Default to MP3
|
|
self.format_selector.setToolTip("Choose download format:\nMP3 = Converted, slower\nBest = Original quality, faster")
|
|
self.format_selector.setFixedWidth(160)
|
|
|
|
self.search_button = QPushButton("GO")
|
|
self.search_button.setObjectName("btn_yt_go")
|
|
self.search_button.setFixedWidth(40)
|
|
self.search_button.setToolTip("Start YouTube search")
|
|
self.search_button.clicked.connect(self.search_youtube)
|
|
|
|
self.settings_btn = QPushButton("MAP")
|
|
self.settings_btn.setFixedWidth(40)
|
|
self.settings_btn.setToolTip("Open Keyboard Mapping Settings")
|
|
self.settings_btn.clicked.connect(self.open_settings)
|
|
|
|
self.status_label = QLabel("")
|
|
self.status_label.setStyleSheet("color:#0f0; font-weight:bold")
|
|
|
|
h.addWidget(self.neon_button)
|
|
h.addSpacing(10)
|
|
h.addWidget(self.yt_input)
|
|
h.addWidget(self.format_selector)
|
|
h.addWidget(self.search_button)
|
|
h.addWidget(self.settings_btn)
|
|
h.addWidget(self.status_label)
|
|
layout.addLayout(h)
|
|
|
|
# Download progress bar
|
|
self.download_progress_bar = QProgressBar()
|
|
self.download_progress_bar.setRange(0, 100)
|
|
self.download_progress_bar.setValue(0)
|
|
self.download_progress_bar.setTextVisible(True)
|
|
self.download_progress_bar.setFormat("%p% - Downloading...")
|
|
self.download_progress_bar.setStyleSheet("""
|
|
QProgressBar {
|
|
border: 1px solid #555;
|
|
border-radius: 3px;
|
|
text-align: center;
|
|
background-color: #111;
|
|
color: #fff;
|
|
}
|
|
QProgressBar::chunk {
|
|
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 #00ff00, stop:1 #00aa00);
|
|
border-radius: 2px;
|
|
}
|
|
""")
|
|
self.download_progress_bar.setVisible(False) # Hidden by default
|
|
layout.addWidget(self.download_progress_bar)
|
|
|
|
# Decks
|
|
decks_layout = QHBoxLayout()
|
|
self.deck_a = DeckWidget("Deck A", "#00ffff", "A")
|
|
decks_layout.addWidget(self.deck_a)
|
|
|
|
self.deck_b = DeckWidget("Deck B", "#ff00ff", "B")
|
|
decks_layout.addWidget(self.deck_b)
|
|
|
|
layout.addLayout(decks_layout, 70)
|
|
|
|
# Crossfader
|
|
self.crossfader = QSlider(Qt.Orientation.Horizontal)
|
|
self.crossfader.setObjectName("crossfader")
|
|
self.crossfader.setRange(0, 100)
|
|
self.crossfader.setValue(50)
|
|
self.crossfader.setToolTip("Crossfade between decks (Left = Deck A, Right = Deck B)")
|
|
self.crossfader.valueChanged.connect(self.update_crossfade)
|
|
|
|
xf_layout = QVBoxLayout()
|
|
xf_layout.setContentsMargins(50, 5, 50, 5)
|
|
xf_layout.addWidget(self.crossfader)
|
|
layout.addLayout(xf_layout, 5)
|
|
|
|
# Recording controls
|
|
rec_layout = QHBoxLayout()
|
|
rec_layout.setContentsMargins(50, 10, 50, 10)
|
|
|
|
self.record_button = QPushButton("REC")
|
|
self.record_button.setFixedWidth(100)
|
|
self.record_button.setToolTip("Start/Stop recording your mix")
|
|
self.record_button.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #330000;
|
|
color: #ff3333;
|
|
border: 2px solid #550000;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
padding: 8px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #550000;
|
|
border-color: #ff0000;
|
|
}
|
|
""")
|
|
self.record_button.clicked.connect(self.toggle_recording)
|
|
|
|
self.recording_timer_label = QLabel("00:00")
|
|
self.recording_timer_label.setStyleSheet("""
|
|
color: #888;
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
font-family: 'Courier New';
|
|
""")
|
|
self.recording_timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
self.recording_status_label = QLabel("Ready to record")
|
|
self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;")
|
|
self.recording_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
rec_left = QVBoxLayout()
|
|
rec_left.addWidget(self.recording_timer_label)
|
|
rec_left.addWidget(self.recording_status_label)
|
|
|
|
# Streaming button
|
|
self.stream_button = QPushButton("LIVE")
|
|
self.stream_button.setFixedWidth(100)
|
|
self.stream_button.setToolTip("Start/Stop live streaming")
|
|
self.stream_button.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #001a33;
|
|
color: #3399ff;
|
|
border: 2px solid #003366;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
padding: 8px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #003366;
|
|
border-color: #0066cc;
|
|
}
|
|
""")
|
|
self.stream_button.clicked.connect(self.toggle_streaming)
|
|
|
|
self.stream_status_label = QLabel("Offline")
|
|
self.stream_status_label.setStyleSheet("color: #666; font-size: 12px;")
|
|
self.stream_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
self.listener_count_label = QLabel("0 listeners")
|
|
self.listener_count_label.setStyleSheet("color: #3399ff; font-size: 10px; font-weight: bold;")
|
|
self.listener_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
stream_info = QVBoxLayout()
|
|
stream_info.addWidget(self.stream_status_label)
|
|
stream_info.addWidget(self.listener_count_label)
|
|
|
|
rec_layout.addStretch()
|
|
rec_layout.addWidget(self.record_button)
|
|
rec_layout.addSpacing(20)
|
|
rec_layout.addLayout(rec_left)
|
|
rec_layout.addSpacing(40)
|
|
rec_layout.addWidget(self.stream_button)
|
|
rec_layout.addSpacing(20)
|
|
rec_layout.addLayout(stream_info)
|
|
rec_layout.addStretch()
|
|
|
|
layout.addLayout(rec_layout, 3)
|
|
|
|
# Library section
|
|
library_group = QGroupBox("LIBRARY")
|
|
lib_layout = QVBoxLayout(library_group)
|
|
|
|
button_row = QHBoxLayout()
|
|
|
|
self.local_mode_btn = QPushButton("LOCAL")
|
|
self.local_mode_btn.setObjectName("btn_lib_local")
|
|
self.local_mode_btn.setCheckable(True)
|
|
self.local_mode_btn.setChecked(True)
|
|
self.local_mode_btn.clicked.connect(lambda: self.set_library_mode("local"))
|
|
|
|
self.server_mode_btn = QPushButton("SERVER")
|
|
self.server_mode_btn.setObjectName("btn_lib_server")
|
|
self.server_mode_btn.setCheckable(True)
|
|
self.server_mode_btn.clicked.connect(lambda: self.set_library_mode("server"))
|
|
|
|
refresh_btn = QPushButton("REFRESH")
|
|
refresh_btn.setToolTip("Rescan library folder for audio files")
|
|
refresh_btn.clicked.connect(self.load_library)
|
|
|
|
upload_btn = QPushButton("UPLOAD")
|
|
upload_btn.setToolTip("Upload track to library")
|
|
upload_btn.clicked.connect(self.upload_track)
|
|
|
|
load_a_btn = QPushButton("LOAD A")
|
|
load_a_btn.setToolTip("Load selected track to Deck A (Ctrl+L)")
|
|
load_a_btn.clicked.connect(lambda: self.load_to_deck(self.deck_a))
|
|
|
|
queue_a_btn = QPushButton("Q A+")
|
|
queue_a_btn.setToolTip("Add selected track to Deck A queue (Ctrl+Shift+L)")
|
|
queue_a_btn.clicked.connect(lambda: self.queue_to_deck(self.deck_a))
|
|
|
|
load_b_btn = QPushButton("LOAD B")
|
|
load_b_btn.setToolTip("Load selected track to Deck B (Ctrl+R)")
|
|
load_b_btn.clicked.connect(lambda: self.load_to_deck(self.deck_b))
|
|
|
|
queue_b_btn = QPushButton("Q B+")
|
|
queue_b_btn.setToolTip("Add selected track to Deck B queue (Ctrl+Shift+R)")
|
|
queue_b_btn.clicked.connect(lambda: self.queue_to_deck(self.deck_b))
|
|
|
|
for btn in [self.local_mode_btn, self.server_mode_btn, refresh_btn, upload_btn, load_a_btn, queue_a_btn, load_b_btn, queue_b_btn]:
|
|
button_row.addWidget(btn)
|
|
lib_layout.addLayout(button_row)
|
|
|
|
self.search_filter = QLineEdit()
|
|
self.search_filter.setPlaceholderText("Filter...")
|
|
self.search_filter.setToolTip("Filter library by filename")
|
|
self.search_filter.textChanged.connect(self.filter_library)
|
|
lib_layout.addWidget(self.search_filter)
|
|
|
|
self.library_list = QListWidget()
|
|
self.library_list.setObjectName("main_lib")
|
|
lib_layout.addWidget(self.library_list)
|
|
|
|
layout.addWidget(library_group, 25)
|
|
|
|
# Initialize crossfade
|
|
self.update_crossfade()
|
|
|
|
def setup_keyboard_shortcuts(self):
|
|
# Clear existing shortcuts if any
|
|
if hasattr(self, '_shortcuts'):
|
|
for s in self._shortcuts:
|
|
s.setParent(None)
|
|
self._shortcuts = []
|
|
|
|
# Mapping actions to methods
|
|
mapping = {
|
|
"Deck A: Load": lambda: self.load_to_deck(self.deck_a),
|
|
"Deck A: Queue": lambda: self.queue_to_deck(self.deck_a),
|
|
"Deck A: Play/Pause": lambda: self.deck_a.play() if self.deck_a.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState else self.deck_a.pause(),
|
|
"Deck B: Load": lambda: self.load_to_deck(self.deck_b),
|
|
"Deck B: Queue": lambda: self.queue_to_deck(self.deck_b),
|
|
"Deck B: Play/Pause": lambda: self.deck_b.play() if self.deck_b.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState else self.deck_b.pause(),
|
|
"Global: Focus Search": lambda: self.search_filter.setFocus(),
|
|
"Global: Toggle Library": lambda: self.set_library_mode("server" if self.library_mode == "local" else "local"),
|
|
}
|
|
|
|
# Default shortcuts
|
|
self.default_shortcuts = {
|
|
"Deck A: Load": "Ctrl+L",
|
|
"Deck A: Queue": "Ctrl+Shift+L",
|
|
"Deck A: Play/Pause": "Space",
|
|
"Deck B: Load": "Ctrl+R",
|
|
"Deck B: Queue": "Ctrl+Shift+R",
|
|
"Deck B: Play/Pause": "Ctrl+Space",
|
|
"Global: Focus Search": "Ctrl+F",
|
|
"Global: Toggle Library": "Ctrl+Tab",
|
|
}
|
|
|
|
# Load all settings from file
|
|
self.settings_file = Path(os.getcwd()) / "settings.json"
|
|
self.all_settings = self.load_all_settings()
|
|
self.current_shortcuts = self.all_settings.get("shortcuts", self.default_shortcuts)
|
|
|
|
# Create shortcuts
|
|
for action, key in self.current_shortcuts.items():
|
|
if action in mapping:
|
|
sc = QShortcut(QKeySequence(key), self)
|
|
sc.activated.connect(mapping[action])
|
|
self._shortcuts.append(sc)
|
|
|
|
def load_all_settings(self):
|
|
"""Load all settings from settings.json"""
|
|
default_settings = {
|
|
"shortcuts": self.default_shortcuts if hasattr(self, 'default_shortcuts') else {},
|
|
"audio": {
|
|
"recording_sample_rate": 48000,
|
|
"recording_format": "wav",
|
|
},
|
|
"ui": {
|
|
"neon_mode": 0,
|
|
},
|
|
"library": {
|
|
"auto_scan": True,
|
|
"yt_default_format": "mp3",
|
|
}
|
|
}
|
|
|
|
if self.settings_file.exists():
|
|
try:
|
|
with open(self.settings_file, "r") as f:
|
|
loaded = json.load(f)
|
|
# Merge with defaults to ensure all keys exist
|
|
for key in default_settings:
|
|
if key not in loaded:
|
|
loaded[key] = default_settings[key]
|
|
return loaded
|
|
except Exception as e:
|
|
print(f"[SETTINGS] Error loading: {e}")
|
|
return default_settings
|
|
return default_settings
|
|
|
|
def apply_ui_settings(self):
|
|
"""Apply UI settings from loaded settings"""
|
|
ui_settings = self.all_settings.get("ui", {})
|
|
neon_mode = ui_settings.get("neon_mode", 0)
|
|
|
|
# Apply neon mode
|
|
if neon_mode != self.neon_state:
|
|
for _ in range(neon_mode):
|
|
self.toggle_neon()
|
|
|
|
# Apply library settings
|
|
library_settings = self.all_settings.get("library", {})
|
|
yt_default = library_settings.get("yt_default_format", "mp3")
|
|
self.format_selector.setCurrentIndex(0 if yt_default == "mp3" else 1)
|
|
|
|
def open_settings(self):
|
|
dialog = SettingsDialog(self.all_settings, self)
|
|
if dialog.exec():
|
|
# Get all updated settings
|
|
self.all_settings = dialog.get_all_settings()
|
|
self.current_shortcuts = self.all_settings["shortcuts"]
|
|
|
|
# Save all settings
|
|
with open(self.settings_file, "w") as f:
|
|
json.dump(self.all_settings, f, indent=4)
|
|
|
|
# Re-setup shortcuts
|
|
self.setup_keyboard_shortcuts()
|
|
|
|
# Apply UI settings
|
|
self.apply_ui_settings()
|
|
|
|
QMessageBox.information(self, "Settings Saved", "All settings have been updated!")
|
|
|
|
def search_youtube(self):
|
|
query = self.yt_input.text().strip()
|
|
if not query:
|
|
return
|
|
|
|
# Check if it's a direct URL
|
|
if "youtube.com/" in query or "youtu.be/" in query or "music.youtube.com/" in query:
|
|
# Direct Download mode
|
|
selected_format = self.format_selector.currentData()
|
|
self.status_label.setText("Downloading...")
|
|
|
|
# Show and reset progress bar
|
|
self.download_progress_bar.setValue(0)
|
|
self.download_progress_bar.setVisible(True)
|
|
|
|
self.download_worker.download(query, str(self.lib_path), selected_format)
|
|
self.yt_input.clear()
|
|
else:
|
|
# Keyword Search mode
|
|
self.status_label.setText("Searching...")
|
|
self.search_button.setEnabled(False)
|
|
self.search_worker.search(query)
|
|
|
|
def on_search_results(self, results):
|
|
self.status_label.setText("")
|
|
self.search_button.setEnabled(True)
|
|
dialog = YTResultDialog(results, self)
|
|
if dialog.exec():
|
|
url = dialog.get_selected_url()
|
|
if url:
|
|
# Get selected format from dropdown
|
|
selected_format = self.format_selector.currentData()
|
|
self.status_label.setText("Downloading...")
|
|
|
|
# Show and reset progress bar
|
|
self.download_progress_bar.setValue(0)
|
|
self.download_progress_bar.setVisible(True)
|
|
|
|
self.download_worker.download(url, str(self.lib_path), selected_format)
|
|
|
|
def update_download_progress(self, percentage):
|
|
"""Update download progress bar"""
|
|
self.download_progress_bar.setValue(int(percentage))
|
|
|
|
def on_download_complete(self, filepath):
|
|
self.status_label.setText("Done!")
|
|
self.download_progress_bar.setVisible(False) # Hide progress bar
|
|
self.load_library()
|
|
QMessageBox.information(self, "Download Complete", f"Saved: {os.path.basename(filepath)}")
|
|
self.status_label.setText("")
|
|
|
|
def on_error(self, error_msg):
|
|
self.status_label.setText("Error")
|
|
self.search_button.setEnabled(True)
|
|
self.download_progress_bar.setVisible(False) # Hide progress bar on error
|
|
QMessageBox.critical(self, "Error", error_msg)
|
|
|
|
def toggle_neon(self):
|
|
self.neon_state = (self.neon_state + 1) % 3
|
|
colors = {0: "#555", 1: "#0ff", 2: "#f0f"}
|
|
color = colors[self.neon_state]
|
|
labels = ["OFF", "BLUE", "PURPLE"]
|
|
|
|
self.neon_button.setText(f"NEON EDGE: {labels[self.neon_state]}")
|
|
self.neon_button.setStyleSheet(f"color: {color}; border: 1px solid {color};")
|
|
|
|
if self.neon_state == 0:
|
|
# Disable glow
|
|
self.glow_frame.set_glow(False)
|
|
self.centralWidget().setStyleSheet("QWidget#Central { border: none; }")
|
|
else:
|
|
# Enable glow with selected color
|
|
self.glow_frame.set_glow(True, color)
|
|
self.centralWidget().setStyleSheet("QWidget#Central { border: none; }")
|
|
|
|
def set_library_mode(self, mode):
|
|
self.library_mode = mode
|
|
self.local_mode_btn.setChecked(mode == "local")
|
|
self.server_mode_btn.setChecked(mode == "server")
|
|
self.load_library()
|
|
|
|
def load_library(self):
|
|
if self.library_mode == "local":
|
|
self.library_list.clear()
|
|
self.library_list.addItem(f"Reading: {self.lib_path.name}...")
|
|
self.library_scanner = LibraryScannerThread(self.lib_path)
|
|
self.library_scanner.files_found.connect(self.populate_library)
|
|
self.library_scanner.start()
|
|
else:
|
|
self.fetch_server_library()
|
|
|
|
def fetch_server_library(self):
|
|
self.library_list.clear()
|
|
self.library_list.addItem("Fetching server library...")
|
|
|
|
base_url = self.get_server_base_url()
|
|
self.server_url = base_url
|
|
|
|
self.fetcher = ServerLibraryFetcher(f"{base_url}/library.json")
|
|
self.fetcher.finished.connect(lambda tracks, err, success: self.on_server_library_fetched(tracks, base_url, err, success))
|
|
self.fetcher.start()
|
|
|
|
def on_server_library_fetched(self, tracks, base_url, err, success):
|
|
self.library_list.clear()
|
|
if success:
|
|
self.server_library = tracks
|
|
self.populate_server_library(tracks, base_url)
|
|
else:
|
|
self.library_list.addItem(f"Error: {err}")
|
|
|
|
def populate_server_library(self, tracks, base_url):
|
|
self.library_list.clear()
|
|
for track in tracks:
|
|
item = QListWidgetItem(track['title'])
|
|
# Store URL and title
|
|
track_url = f"{base_url}/{track['file']}"
|
|
item.setData(Qt.ItemDataRole.UserRole, {"url": track_url, "title": track['title'], "is_server": True})
|
|
self.library_list.addItem(item)
|
|
|
|
def populate_library(self, files):
|
|
self.library_list.clear()
|
|
self.local_library = []
|
|
for file_path in files:
|
|
item = QListWidgetItem(file_path.name)
|
|
data = {"path": file_path, "title": file_path.stem, "is_server": False}
|
|
item.setData(Qt.ItemDataRole.UserRole, data)
|
|
self.library_list.addItem(item)
|
|
self.local_library.append(data)
|
|
|
|
def filter_library(self, filter_text):
|
|
# Debounce the search to prevent UI freezing while typing
|
|
self.filter_timer.start(250)
|
|
|
|
def perform_filter(self):
|
|
filter_text = self.search_filter.text().lower().strip()
|
|
self.library_list.setUpdatesEnabled(False)
|
|
for i in range(self.library_list.count()):
|
|
item = self.library_list.item(i)
|
|
is_match = not filter_text or filter_text in item.text().lower()
|
|
item.setHidden(not is_match)
|
|
self.library_list.setUpdatesEnabled(True)
|
|
|
|
def load_to_deck(self, deck):
|
|
item = self.library_list.currentItem()
|
|
if item:
|
|
data = item.data(Qt.ItemDataRole.UserRole)
|
|
if data:
|
|
if data.get("is_server"):
|
|
self.load_server_track(deck, data)
|
|
else:
|
|
path = data.get("path")
|
|
if path:
|
|
deck.load_track(path)
|
|
|
|
def load_server_track(self, deck, data):
|
|
url = data.get("url")
|
|
title = data.get("title")
|
|
filename = os.path.basename(url)
|
|
cache_path = self.cache_dir / filename
|
|
|
|
if cache_path.exists():
|
|
deck.load_track(cache_path)
|
|
else:
|
|
self.status_label.setText(f"Downloading: {title}...")
|
|
thread = DownloadThread(url, str(cache_path))
|
|
thread.finished.connect(lambda path, success: self.on_server_download_complete(deck, path, success))
|
|
thread.start()
|
|
self.download_threads[filename] = thread
|
|
|
|
def on_server_download_complete(self, deck, path, success):
|
|
self.status_label.setText("Download complete")
|
|
if success:
|
|
deck.load_track(Path(path))
|
|
else:
|
|
QMessageBox.warning(self, "Download Error", "Failed to download track from server")
|
|
|
|
def queue_to_deck(self, deck):
|
|
item = self.library_list.currentItem()
|
|
if item:
|
|
data = item.data(Qt.ItemDataRole.UserRole)
|
|
if data:
|
|
if data.get("is_server"):
|
|
# For server queueing, we download first then queue
|
|
url = data.get("url")
|
|
filename = os.path.basename(url)
|
|
cache_path = self.cache_dir / filename
|
|
if cache_path.exists():
|
|
deck.add_queue(cache_path)
|
|
else:
|
|
thread = DownloadThread(url, str(cache_path))
|
|
thread.finished.connect(lambda path, success: deck.add_queue(Path(path)) if success else None)
|
|
thread.start()
|
|
self.download_threads[os.path.basename(url)] = thread
|
|
else:
|
|
path = data.get("path")
|
|
if path:
|
|
deck.add_queue(path)
|
|
|
|
def upload_track(self):
|
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select Track to Upload", "", "Audio Files (*.mp3 *.wav *.m4a *.flac *.ogg)")
|
|
if not file_path:
|
|
return
|
|
|
|
if self.library_mode == "local":
|
|
# Copy to local music folder
|
|
dest = self.lib_path / os.path.basename(file_path)
|
|
try:
|
|
shutil.copy2(file_path, dest)
|
|
self.status_label.setText(f"Imported: {os.path.basename(file_path)}")
|
|
self.load_library()
|
|
except Exception as e:
|
|
QMessageBox.warning(self, "Import Error", f"Failed to import file: {e}")
|
|
else:
|
|
# Upload to server
|
|
try:
|
|
self.status_label.setText("Uploading to server...")
|
|
base_url = self.get_server_base_url()
|
|
|
|
with open(file_path, 'rb') as f:
|
|
files = {'file': f}
|
|
response = requests.post(f"{base_url}/upload", files=files, timeout=60)
|
|
|
|
if response.status_code == 200:
|
|
self.status_label.setText("Upload successful!")
|
|
self.load_library()
|
|
else:
|
|
try:
|
|
err = response.json().get('error', 'Unknown error')
|
|
except:
|
|
err = f"Server returned {response.status_code}"
|
|
QMessageBox.warning(self, "Upload Error", f"Server error: {err}")
|
|
self.status_label.setText("Upload failed")
|
|
except Exception as e:
|
|
QMessageBox.warning(self, "Upload Error", f"Failed to upload: {e}")
|
|
self.status_label.setText("Upload error")
|
|
|
|
def update_crossfade(self):
|
|
value = self.crossfader.value()
|
|
ratio = value / 100.0
|
|
|
|
# Cosine crossfade curve for smooth transition
|
|
deck_a_vol = int(math.cos(ratio * 0.5 * math.pi) * 100)
|
|
deck_b_vol = int(math.cos((1 - ratio) * 0.5 * math.pi) * 100)
|
|
|
|
self.deck_a.set_xf_vol(deck_a_vol)
|
|
self.deck_b.set_xf_vol(deck_b_vol)
|
|
|
|
def toggle_recording(self):
|
|
"""Start or stop recording"""
|
|
if not self.is_recording:
|
|
# Start recording
|
|
from datetime import datetime
|
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
filename = f"mix_{timestamp}.wav"
|
|
output_path = str(self.recordings_path / filename)
|
|
|
|
if self.recording_worker.start_recording(output_path):
|
|
self.is_recording = True
|
|
self.recording_start_time = time.time()
|
|
self.recording_timer.start(1000) # Update every second
|
|
|
|
# Update UI
|
|
self.record_button.setText("STOP")
|
|
self.record_button.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #550000;
|
|
color: #ff0000;
|
|
border: 2px solid #ff0000;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
padding: 8px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #770000;
|
|
border-color: #ff3333;
|
|
}
|
|
""")
|
|
self.recording_status_label.setText(f"Recording to: {filename}")
|
|
self.recording_status_label.setStyleSheet("color: #ff0000; font-size: 12px;")
|
|
print(f"[RECORDING] Started: {output_path}")
|
|
else:
|
|
# Stop recording
|
|
self.recording_worker.stop_recording()
|
|
self.is_recording = False
|
|
self.recording_timer.stop()
|
|
|
|
# Update UI
|
|
self.record_button.setText("REC")
|
|
self.record_button.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #330000;
|
|
color: #ff3333;
|
|
border: 2px solid #550000;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
padding: 8px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #550000;
|
|
border-color: #ff0000;
|
|
}
|
|
""")
|
|
self.recording_timer_label.setText("00:00")
|
|
self.recording_timer_label.setStyleSheet("color: #888; font-size: 24px; font-weight: bold; font-family: 'Courier New';")
|
|
self.recording_status_label.setText("Recording saved!")
|
|
self.recording_status_label.setStyleSheet("color: #00ff00; font-size: 12px;")
|
|
print("[RECORDING] Stopped")
|
|
|
|
# Reset status after 3 seconds
|
|
QTimer.singleShot(3000, lambda: self.recording_status_label.setText("Ready to record") or self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;"))
|
|
|
|
def update_recording_time(self):
|
|
"""Update the recording timer display"""
|
|
if self.is_recording:
|
|
elapsed = int(time.time() - self.recording_start_time)
|
|
minutes = elapsed // 60
|
|
seconds = elapsed % 60
|
|
self.recording_timer_label.setText(f"{minutes:02d}:{seconds:02d}")
|
|
self.recording_timer_label.setStyleSheet("color: #ff0000; font-size: 24px; font-weight: bold; font-family: 'Courier New';")
|
|
|
|
def on_recording_error(self, error_msg):
|
|
"""Handle recording errors"""
|
|
QMessageBox.critical(self, "Recording Error", error_msg)
|
|
if self.is_recording:
|
|
self.is_recording = False
|
|
self.recording_timer.stop()
|
|
self.record_button.setText("REC")
|
|
self.recording_status_label.setText("Recording failed")
|
|
self.recording_status_label.setStyleSheet("color: #ff0000; font-size: 12px;")
|
|
|
|
def toggle_streaming(self):
|
|
"""Toggle live streaming on/off"""
|
|
if not self.is_streaming:
|
|
# Get base URL from settings
|
|
base_url = self.get_server_base_url()
|
|
bitrate = self.all_settings.get("audio", {}).get("bitrate", 128)
|
|
|
|
# Start streaming
|
|
if self.streaming_worker.start_streaming(base_url, bitrate):
|
|
self.is_streaming = True
|
|
self.stream_button.setText("STOP")
|
|
self.stream_button.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #003366;
|
|
color: #00ff00;
|
|
border: 2px solid #0066cc;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
padding: 8px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #0066cc;
|
|
border-color: #00ff00;
|
|
}
|
|
""")
|
|
self.stream_status_label.setText("LIVE")
|
|
self.stream_status_label.setStyleSheet("color: #ff0000; font-size: 12px; font-weight: bold;")
|
|
print("[STREAMING] Started")
|
|
else:
|
|
# Stop streaming
|
|
self.streaming_worker.stop_streaming()
|
|
self.is_streaming = False
|
|
self.stream_button.setText("LIVE")
|
|
self.stream_button.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #001a33;
|
|
color: #3399ff;
|
|
border: 2px solid #003366;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
padding: 8px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #003366;
|
|
border-color: #0066cc;
|
|
}
|
|
""")
|
|
self.stream_status_label.setText("Offline")
|
|
self.stream_status_label.setStyleSheet("color: #666; font-size: 12px;")
|
|
print("[STREAMING] Stopped")
|
|
|
|
def on_streaming_error(self, error_msg):
|
|
"""Handle streaming errors"""
|
|
QMessageBox.critical(self, "Streaming Error", error_msg)
|
|
if self.is_streaming:
|
|
self.is_streaming = False
|
|
self.stream_button.setText("LIVE")
|
|
self.stream_status_label.setText("Error")
|
|
self.stream_status_label.setStyleSheet("color: #ff0000; font-size: 12px;")
|
|
|
|
def update_listener_count(self, count):
|
|
self.listener_count_label.setText(f"{count} listeners")
|
|
|
|
def get_server_base_url(self):
|
|
audio_settings = self.all_settings.get("audio", {})
|
|
server_url = audio_settings.get("stream_server_url", "http://localhost:5000")
|
|
|
|
# Normal techdj server runs on 5000 (DJ) and 5001 (Listener)
|
|
# If the URL is for the listener or stream, switch to 5000
|
|
if ":5001" in server_url:
|
|
return server_url.split(":5001")[0] + ":5000"
|
|
elif ":8080" in server_url:
|
|
return server_url.split(":8080")[0] + ":5000"
|
|
elif "/api/stream" in server_url:
|
|
return server_url.split("/api/stream")[0].rstrip("/")
|
|
|
|
if server_url.endswith("/"): server_url = server_url[:-1]
|
|
return server_url
|
|
|
|
def resizeEvent(self, event):
|
|
"""Update glow frame size when window is resized"""
|
|
super().resizeEvent(event)
|
|
if hasattr(self, 'glow_frame'):
|
|
self.glow_frame.setGeometry(self.centralWidget().rect())
|
|
|
|
def closeEvent(self, event):
|
|
# Stop recording if active
|
|
if self.is_recording:
|
|
self.recording_worker.stop_recording()
|
|
|
|
# Stop streaming if active
|
|
if self.is_streaming:
|
|
self.streaming_worker.stop_streaming()
|
|
|
|
self.deck_a.stop()
|
|
self.deck_b.stop()
|
|
self.search_worker.kill()
|
|
self.download_worker.kill()
|
|
event.accept()
|
|
|
|
if __name__ == "__main__":
|
|
app = QApplication(sys.argv)
|
|
app.setApplicationName("TechDJ Pro")
|
|
app.setDesktopFileName("techdj-pro")
|
|
window = DJApp()
|
|
window.show()
|
|
sys.exit(app.exec())
|