3860 lines
156 KiB
Python
3860 lines
156 KiB
Python
#!/usr/bin/env python3
|
||
import sys
|
||
import os
|
||
import json
|
||
import random
|
||
import math
|
||
import time
|
||
import queue
|
||
import shutil
|
||
import threading
|
||
import requests
|
||
import re
|
||
import socketio
|
||
from urllib.parse import urlparse
|
||
import subprocess
|
||
import atexit
|
||
from pathlib import Path
|
||
import soundfile as sf
|
||
import numpy as np
|
||
|
||
try:
|
||
import librosa
|
||
HAS_LIBROSA = True
|
||
except ImportError:
|
||
HAS_LIBROSA = False
|
||
print("[INFO] librosa not found - BPM detection disabled. Run 'pip install librosa'")
|
||
|
||
# --- 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"
|
||
|
||
# --- SUPPRESS FFMPEG AV_LOG NOISE ---
|
||
# Qt6's internal FFmpeg multimedia backend emits AV_LOG_WARNING messages
|
||
# (e.g. "Could not update timestamps for skipped/discarded samples") directly
|
||
# to stderr via av_log. Set the threshold to AV_LOG_ERROR so only genuine
|
||
# errors are printed. This affects only the in-process libavutil shared
|
||
# library; subprocess ffmpeg invocations are unaffected.
|
||
try:
|
||
import ctypes, ctypes.util as _ctypes_util
|
||
_avutil_name = _ctypes_util.find_library('avutil')
|
||
if _avutil_name:
|
||
_libavutil = ctypes.CDLL(_avutil_name)
|
||
_AV_LOG_ERROR = 16 # suppress warnings (24) but keep errors (16)
|
||
_libavutil.av_log_set_level(_AV_LOG_ERROR)
|
||
except Exception:
|
||
pass
|
||
|
||
# --- 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, QInputDialog)
|
||
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,
|
||
QLinearGradient, QPolygonF)
|
||
|
||
# --- 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
|
||
|
||
# --- EFFECTS DEFINITIONS ---
|
||
# Each entry maps an effect ID to its ffmpeg -af filter fragment.
|
||
# Segments are joined with ',' to build sequential filter chains.
|
||
FX_DEFS = {
|
||
'echo': ['aecho=0.8:0.88:60:0.4'],
|
||
'reverb': ['aecho=0.8:0.7:100:0.35', 'aecho=0.5:0.5:300:0.2', 'aecho=0.3:0.3:600:0.1'],
|
||
'flange': ['flanger=delay=0:depth=2:regen=0:width=71:speed=0.5:phase=25:shape=sinusoidal'],
|
||
'phase': ['aphaser=in_gain=0.4:out_gain=0.74:delay=3:decay=0.4:speed=0.5:type=t'],
|
||
'lpf': ['lowpass=f=700:poles=2'],
|
||
'hpf': ['highpass=f=2800:poles=2'],
|
||
'crush': ['acompressor=threshold=0.04:ratio=20:attack=0:release=0.01', 'volume=5', 'lowpass=f=7000'],
|
||
'comb': ['aecho=0.9:0.9:11:0.9'],
|
||
'bass': ['bass=g=9'],
|
||
'treble': ['treble=g=8'],
|
||
}
|
||
|
||
# Per-effect display config: (label, accent_color)
|
||
STYLESHEET = """
|
||
QMainWindow { background-color: #020408; }
|
||
QWidget { color: #e0e0e0; }
|
||
|
||
/* ── Group boxes ── */
|
||
QGroupBox {
|
||
background-color: #06090f;
|
||
border-radius: 6px;
|
||
margin-top: 14px;
|
||
font-family: "Courier New";
|
||
font-size: 11px;
|
||
}
|
||
QGroupBox::title {
|
||
subcontrol-origin: margin;
|
||
left: 12px;
|
||
padding: 0 5px;
|
||
font-weight: bold;
|
||
font-size: 12px;
|
||
letter-spacing: 2px;
|
||
}
|
||
QGroupBox#Deck_A {
|
||
border: 2px solid #00e5ff;
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #030d12, stop:1 #020408);
|
||
}
|
||
QGroupBox#Deck_A::title { color: #00e5ff; }
|
||
QGroupBox#Deck_B {
|
||
border: 2px solid #ea00ff;
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #0d0312, stop:1 #020408);
|
||
}
|
||
QGroupBox#Deck_B::title { color: #ea00ff; }
|
||
|
||
/* ── Generic buttons ── */
|
||
QPushButton {
|
||
background-color: #0d1117;
|
||
color: #b0b8c8;
|
||
border: 1px solid #2a3040;
|
||
padding: 6px 10px;
|
||
font-weight: bold;
|
||
font-family: "Courier New";
|
||
letter-spacing: 1px;
|
||
border-radius: 3px;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #151c28;
|
||
border: 1px solid #00e5ff;
|
||
color: #00e5ff;
|
||
}
|
||
QPushButton:pressed {
|
||
background-color: #00e5ff;
|
||
color: #000;
|
||
}
|
||
|
||
/* ── Neon toggle ── */
|
||
QPushButton#btn_neon {
|
||
font-family: "Courier New";
|
||
font-size: 11px;
|
||
letter-spacing: 2px;
|
||
border: 1px solid #2a3040;
|
||
color: #556677;
|
||
}
|
||
|
||
/* ── YouTube GO button ── */
|
||
QPushButton#btn_yt_go {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #cc0000, stop:1 #880000);
|
||
border: 1px solid #ff2222;
|
||
color: #fff;
|
||
font-weight: bold;
|
||
letter-spacing: 1px;
|
||
}
|
||
QPushButton#btn_yt_go:hover { background: #ff0000; border-color: #ff6666; color: #fff; }
|
||
|
||
/* ── Remove button ── */
|
||
QPushButton#btn_remove {
|
||
background-color: #1a0000;
|
||
color: #ff3333;
|
||
border: 1px solid #440000;
|
||
padding: 0px;
|
||
font-size: 10px;
|
||
min-width: 22px;
|
||
min-height: 22px;
|
||
border-radius: 2px;
|
||
}
|
||
QPushButton#btn_remove:hover { background-color: #cc0000; color: #fff; border-color: #ff4444; }
|
||
|
||
/* ── Loop beat buttons ── */
|
||
QPushButton#btn_loop {
|
||
background-color: #0d0d0d;
|
||
color: #556066;
|
||
border: 1px solid #1a2233;
|
||
font-size: 11px;
|
||
font-family: "Courier New";
|
||
padding: 4px 2px;
|
||
}
|
||
QPushButton#btn_loop:hover { border-color: #ff9900; color: #ff9900; background: #111800; }
|
||
QPushButton#btn_loop:checked {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #ffcc00, stop:1 #ff6600);
|
||
color: #000;
|
||
border: 1px solid #ffcc00;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* ── Cue buttons ── */
|
||
QPushButton#btn_cue {
|
||
background-color: #0a1500;
|
||
color: #44cc00;
|
||
border: 1px solid #1a4400;
|
||
font-size: 11px;
|
||
font-family: "Courier New";
|
||
font-weight: bold;
|
||
padding: 5px 4px;
|
||
}
|
||
QPushButton#btn_cue:hover { border-color: #88ff00; color: #88ff00; background: #0f1f00; }
|
||
QPushButton#btn_cue:pressed { background: #44cc00; color: #000; border-color: #88ff22; }
|
||
|
||
/* ── Hot-cue buttons (1-4) ── */
|
||
QPushButton#btn_hotcue1 { background: #0a0015; color: #9933ff; border: 1px solid #330066; font-family: "Courier New"; font-size: 10px; font-weight: bold; }
|
||
QPushButton#btn_hotcue1:hover { background: #220033; border-color: #cc44ff; color: #cc44ff; }
|
||
QPushButton#btn_hotcue1:checked { background: #9933ff; color: #fff; border-color: #cc66ff; }
|
||
QPushButton#btn_hotcue2 { background: #001515; color: #00ccbb; border: 1px solid #004444; font-family: "Courier New"; font-size: 10px; font-weight: bold; }
|
||
QPushButton#btn_hotcue2:hover { background: #002222; border-color: #00ffee; color: #00ffee; }
|
||
QPushButton#btn_hotcue2:checked { background: #00ccbb; color: #000; border-color: #00ffee; }
|
||
QPushButton#btn_hotcue3 { background: #150a00; color: #ff7700; border: 1px solid #442200; font-family: "Courier New"; font-size: 10px; font-weight: bold; }
|
||
QPushButton#btn_hotcue3:hover { background: #221100; border-color: #ffaa33; color: #ffaa33; }
|
||
QPushButton#btn_hotcue3:checked { background: #ff7700; color: #000; border-color: #ffaa33; }
|
||
QPushButton#btn_hotcue4 { background: #15000a; color: #ff3388; border: 1px solid #440022; font-family: "Courier New"; font-size: 10px; font-weight: bold; }
|
||
QPushButton#btn_hotcue4:hover { background: #220011; border-color: #ff66aa; color: #ff66aa; }
|
||
QPushButton#btn_hotcue4:checked { background: #ff3388; color: #fff; border-color: #ff66aa; }
|
||
|
||
/* ── Loop exit ── */
|
||
QPushButton#btn_loop_exit { color: #ff3333; border: 1px solid #440000; font-size: 11px; font-family: "Courier New"; }
|
||
QPushButton#btn_loop_exit:hover { background-color: #220000; border-color: #ff0000; }
|
||
QPushButton#btn_loop_exit:pressed { background-color: #ff0000; color: #fff; }
|
||
|
||
/* ── Playback mode button states ── */
|
||
QPushButton[mode="0"] { color: #00ff66; border-color: #005522; background: #030f06; }
|
||
QPushButton[mode="1"] { color: #ffaa00; border-color: #554400; background: #0f0900; }
|
||
QPushButton[mode="2"] { color: #ff3333; border-color: #440000; background: #0f0000; }
|
||
|
||
/* ── Library mode buttons ── */
|
||
QPushButton#btn_lib_local { color: #00e5ff; border-color: #005566; font-family: "Courier New"; letter-spacing: 1px; }
|
||
QPushButton#btn_lib_local:checked {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #00e5ff, stop:1 #007788);
|
||
color: #000;
|
||
font-weight: bold;
|
||
border: 1px solid #00ffff;
|
||
}
|
||
QPushButton#btn_lib_server { color: #ea00ff; border-color: #550066; font-family: "Courier New"; letter-spacing: 1px; }
|
||
QPushButton#btn_lib_server:checked {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #ea00ff, stop:1 #880099);
|
||
color: #000;
|
||
font-weight: bold;
|
||
border: 1px solid #ff00ff;
|
||
}
|
||
|
||
/* ── Play / Pause / Stop ── */
|
||
QPushButton#btn_play {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #003300, stop:1 #001a00);
|
||
color: #00ff66;
|
||
border: 1px solid #007733;
|
||
font-family: "Courier New";
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
letter-spacing: 2px;
|
||
}
|
||
QPushButton#btn_play:hover { background: #005500; border-color: #00ff66; }
|
||
QPushButton#btn_play:pressed { background: #00ff66; color: #000; }
|
||
|
||
QPushButton#btn_pause {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a1100, stop:1 #0d0900);
|
||
color: #ffcc00;
|
||
border: 1px solid #554400;
|
||
font-family: "Courier New";
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
letter-spacing: 2px;
|
||
}
|
||
QPushButton#btn_pause:hover { background: #332200; border-color: #ffcc00; }
|
||
QPushButton#btn_pause:pressed { background: #ffcc00; color: #000; }
|
||
|
||
QPushButton#btn_stop {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a0000, stop:1 #0d0000);
|
||
color: #ff3333;
|
||
border: 1px solid #550000;
|
||
font-family: "Courier New";
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
letter-spacing: 2px;
|
||
}
|
||
QPushButton#btn_stop:hover { background: #330000; border-color: #ff0000; }
|
||
QPushButton#btn_stop:pressed { background: #ff0000; color: #fff; }
|
||
|
||
/* ── Line edits ── */
|
||
QLineEdit {
|
||
background-color: #080d14;
|
||
color: #d0f0ff;
|
||
border: 1px solid #1a3044;
|
||
padding: 6px 8px;
|
||
font-family: "Courier New";
|
||
border-radius: 2px;
|
||
}
|
||
QLineEdit:focus { border: 1px solid #00e5ff; color: #fff; background: #0a1520; }
|
||
QLineEdit::placeholder { color: #334455; }
|
||
|
||
/* ── List widgets ── */
|
||
QListWidget {
|
||
background-color: #050a10;
|
||
border: 1px solid #1a2233;
|
||
color: #7a8fa8;
|
||
font-family: "Courier New";
|
||
font-size: 12px;
|
||
alternate-background-color: #070e16;
|
||
}
|
||
QListWidget::item { padding: 3px 6px; border-bottom: 1px solid #0d1520; }
|
||
QListWidget::item:hover { background: #0d1a26; color: #aaccdd; }
|
||
QListWidget::item:selected { background-color: #0d2030; color: #00e5ff; border: 1px solid #00e5ff; }
|
||
QListWidget#queue_list { border: 1px solid #1a0d26; background: #050208; }
|
||
QListWidget#queue_list::item:selected { background-color: #1a0d22; color: #ea00ff; border: 1px solid #660099; }
|
||
|
||
/* ── Scrollbar (thin neon style) ── */
|
||
QScrollBar:vertical { background: #050a10; width: 8px; border: none; }
|
||
QScrollBar::handle:vertical { background: #1a3044; border-radius: 4px; min-height: 20px; }
|
||
QScrollBar::handle:vertical:hover { background: #00e5ff; }
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||
|
||
/* ── Horizontal sliders (pitch etc.) ── */
|
||
QSlider::groove:horizontal { border: 1px solid #1a2233; height: 4px; background: #0d1520; border-radius: 2px; }
|
||
QSlider::handle:horizontal {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #ddeeff, stop:1 #8899aa);
|
||
border: 1px solid #aabbcc;
|
||
width: 14px; height: 14px;
|
||
margin: -6px 0;
|
||
border-radius: 7px;
|
||
}
|
||
QSlider::handle:horizontal:hover { background: #fff; border-color: #00e5ff; }
|
||
QSlider::sub-page:horizontal { background: #003344; border-radius: 2px; }
|
||
|
||
/* ── Vertical EQ / fader sliders ── */
|
||
QSlider::groove:vertical { border: 1px solid #1a2233; width: 6px; background: #080d14; border-radius: 3px; }
|
||
QSlider::handle:vertical { background: #99b0c0; border: 1px solid #ccd0d8; height: 14px; width: 14px; margin: 0 -5px; border-radius: 3px; }
|
||
QSlider::sub-page:vertical { background: #1a3344; border-radius: 3px; }
|
||
QSlider::add-page:vertical { background: #0d1520; border-radius: 3px; }
|
||
QSlider[eq="vol"]::handle:vertical { background: #ccd8e8; border: 1px solid #fff; }
|
||
QSlider[eq="vol"]::sub-page:vertical { background: qlineargradient(x1:0,y1:1,x2:0,y2:0, stop:0 #005588, stop:1 #00aadd); }
|
||
QSlider[eq="high"]::handle:vertical { background: #00e5ff; border: 1px solid #00ffff; }
|
||
QSlider[eq="high"]::sub-page:vertical { background: qlineargradient(x1:0,y1:1,x2:0,y2:0, stop:0 #003344, stop:1 #00aacc); }
|
||
QSlider[eq="mid"]::handle:vertical { background: #00ff88; border: 1px solid #00ff66; }
|
||
QSlider[eq="mid"]::sub-page:vertical { background: qlineargradient(x1:0,y1:1,x2:0,y2:0, stop:0 #003322, stop:1 #00aa55); }
|
||
QSlider[eq="low"]::handle:vertical { background: #ff3344; border: 1px solid #ff0000; }
|
||
QSlider[eq="low"]::sub-page:vertical { background: qlineargradient(x1:0,y1:1,x2:0,y2:0, stop:0 #330000, stop:1 #cc0022); }
|
||
|
||
/* ── Crossfader ── */
|
||
QSlider#crossfader::groove:horizontal {
|
||
border: 1px solid #334455;
|
||
height: 18px;
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0.0 #001a22,
|
||
stop:0.15 #00e5ff,
|
||
stop:0.45 #001520,
|
||
stop:0.5 #050a0f,
|
||
stop:0.55 #1a0022,
|
||
stop:0.85 #ea00ff,
|
||
stop:1.0 #1a0022);
|
||
border-radius: 9px;
|
||
}
|
||
QSlider#crossfader::handle:horizontal {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #e8eef8, stop:0.5 #9aaabb, stop:1 #556677);
|
||
border: 2px solid #00e5ff;
|
||
width: 34px;
|
||
height: 40px;
|
||
margin: -12px 0;
|
||
border-radius: 5px;
|
||
}
|
||
QSlider#crossfader::handle:horizontal:hover {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #fff, stop:1 #aabbcc);
|
||
border-color: #00ff88;
|
||
}
|
||
|
||
/* ── Combo box ── */
|
||
QComboBox {
|
||
background: #080d14;
|
||
color: #aaccdd;
|
||
border: 1px solid #1a3044;
|
||
padding: 5px 8px;
|
||
font-family: "Courier New";
|
||
font-size: 11px;
|
||
border-radius: 2px;
|
||
}
|
||
QComboBox:hover { border-color: #00e5ff; }
|
||
QComboBox QAbstractItemView {
|
||
background: #0d1520;
|
||
color: #aaccdd;
|
||
border: 1px solid #1a3044;
|
||
selection-background-color: #0d2030;
|
||
selection-color: #00e5ff;
|
||
}
|
||
QComboBox::drop-down { border: none; width: 18px; }
|
||
QComboBox::down-arrow { border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 6px solid #00e5ff; }
|
||
|
||
/* ── Labels ── */
|
||
QLabel { color: #7a8fa8; }
|
||
|
||
/* ── Progress bar ── */
|
||
QProgressBar {
|
||
border: 1px solid #1a3044;
|
||
border-radius: 3px;
|
||
text-align: center;
|
||
background: #080d14;
|
||
color: #00ff88;
|
||
font-family: "Courier New";
|
||
font-size: 11px;
|
||
height: 14px;
|
||
}
|
||
QProgressBar::chunk {
|
||
background: qlineargradient(x1:0,y1:0,x2:1,y2:0, stop:0 #007840, stop:0.6 #00cc66, stop:1 #00ff88);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* ── Tab widget (settings dialog) ── */
|
||
QTabWidget::pane { border: 1px solid #1a2233; background: #06090f; }
|
||
QTabBar::tab { background: #0d1117; color: #556677; padding: 8px 18px; margin: 2px; font-family: "Courier New"; letter-spacing: 1px; border: 1px solid #1a2233; border-radius: 2px; }
|
||
QTabBar::tab:selected { background: #001a22; color: #00e5ff; border-color: #00e5ff; font-weight: bold; }
|
||
QTabBar::tab:hover { background: #0d1520; color: #aabbcc; }
|
||
|
||
/* ── Table (shortcuts) ── */
|
||
QTableWidget { background: #050a10; color: #7a8fa8; border: 1px solid #1a2233; font-family: "Courier New"; gridline-color: #0d1520; }
|
||
QHeaderView::section { background: #0d1520; color: #00e5ff; border: 1px solid #1a2233; padding: 4px; font-family: "Courier New"; letter-spacing: 1px; }
|
||
QTableWidget::item:selected { background: #0d2030; color: #00e5ff; }
|
||
|
||
/* ── Spin box ── */
|
||
QSpinBox { background: #080d14; color: #aaccdd; border: 1px solid #1a3044; padding: 4px; font-family: "Courier New"; }
|
||
QSpinBox:focus { border-color: #00e5ff; }
|
||
|
||
/* ── Check box ── */
|
||
QCheckBox { color: #7a8fa8; font-family: "Courier New"; spacing: 6px; }
|
||
QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #1a3044; background: #080d14; border-radius: 2px; }
|
||
QCheckBox::indicator:checked { background: #00e5ff; border-color: #00ffff; }
|
||
"""
|
||
|
||
# --- AUDIO ISOLATION ---
|
||
# We create a PulseAudio virtual null sink and set PULSE_SINK *before* Qt
|
||
# initialises audio. That forces ALL audio from this process to the virtual
|
||
# sink automatically – no fragile PID-based routing needed.
|
||
#
|
||
# A loopback copies the virtual-sink audio back to the real speakers so the
|
||
# DJ still hears their mix. Streaming/recording capture from the virtual
|
||
# sink's monitor, which contains ONLY this app's audio.
|
||
|
||
_AUDIO_SINK_NAME = "techdj_stream"
|
||
_AUDIO_MONITOR = f"{_AUDIO_SINK_NAME}.monitor"
|
||
_audio_sink_module = None # pactl module ID for the null sink
|
||
_audio_lb_module = None # pactl module ID for the loopback
|
||
_audio_isolated = False # True when the virtual sink is active
|
||
|
||
|
||
def _cleanup_stale_sinks():
|
||
"""Remove leftover techdj_stream modules from a previous crash."""
|
||
try:
|
||
result = subprocess.run(
|
||
['pactl', 'list', 'modules', 'short'],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
for line in result.stdout.strip().split('\n'):
|
||
if _AUDIO_SINK_NAME in line and (
|
||
'module-null-sink' in line or 'module-loopback' in line):
|
||
mod_id = line.split()[0]
|
||
subprocess.run(['pactl', 'unload-module', mod_id],
|
||
capture_output=True, timeout=5)
|
||
print(f"[AUDIO] Cleaned up stale module {mod_id}")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _find_physical_sink():
|
||
"""Return the name of the best physical (non-virtual, non-Bluetooth) output sink,
|
||
or None if none can be found. Prefers ALSA/PCI (built-in) sinks."""
|
||
try:
|
||
r = subprocess.run(['pactl', 'list', 'sinks', 'short'],
|
||
capture_output=True, text=True, timeout=5)
|
||
for line in r.stdout.splitlines():
|
||
parts = line.split()
|
||
if len(parts) < 2:
|
||
continue
|
||
name = parts[1]
|
||
# Prefer built-in analogue / PCI entries
|
||
if 'alsa' in name.lower() or 'pci' in name.lower() or 'analog' in name.lower():
|
||
return name
|
||
# Second pass: accept any non-virtual, non-Bluetooth entry
|
||
for line in r.stdout.splitlines():
|
||
parts = line.split()
|
||
if len(parts) < 2:
|
||
continue
|
||
name = parts[1]
|
||
if 'bluez' not in name.lower() and _AUDIO_SINK_NAME not in name:
|
||
return name
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _setup_audio_isolation():
|
||
"""Create the virtual sink and set PULSE_SINK.
|
||
|
||
**MUST** be called before QApplication() so that Qt's audio output
|
||
is automatically routed to the virtual sink.
|
||
"""
|
||
global _audio_sink_module, _audio_lb_module, _audio_isolated
|
||
|
||
if not shutil.which('pactl'):
|
||
print('[AUDIO] pactl not found – audio isolation disabled '
|
||
'(install pulseaudio-utils or pipewire-pulse)')
|
||
return
|
||
|
||
_cleanup_stale_sinks()
|
||
|
||
# 1. Create null sink
|
||
r = subprocess.run(
|
||
['pactl', 'load-module', 'module-null-sink',
|
||
f'sink_name={_AUDIO_SINK_NAME}',
|
||
f'sink_properties=device.description="TechDJ_Stream"'],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
if r.returncode != 0:
|
||
print(f'[AUDIO] Failed to create virtual sink: {r.stderr.strip()}')
|
||
return
|
||
_audio_sink_module = r.stdout.strip()
|
||
|
||
# 2. Loopback → real speakers so the DJ hears the mix.
|
||
# Explicitly target the best physical sink so Bluetooth-as-default doesn't
|
||
# silently swallow the audio.
|
||
phys_sink = _find_physical_sink()
|
||
lb_cmd = ['pactl', 'load-module', 'module-loopback',
|
||
f'source={_AUDIO_MONITOR}', 'latency_msec=30']
|
||
if phys_sink:
|
||
lb_cmd.append(f'sink={phys_sink}')
|
||
print(f'[AUDIO] Routing loopback to: {phys_sink}')
|
||
r = subprocess.run(lb_cmd, capture_output=True, text=True, timeout=5)
|
||
if r.returncode == 0:
|
||
_audio_lb_module = r.stdout.strip()
|
||
else:
|
||
print(f'[AUDIO] Loopback failed (DJ may not hear audio): {r.stderr.strip()}')
|
||
|
||
# 3. Verify the sink and its monitor actually exist
|
||
verify = subprocess.run(
|
||
['pactl', 'list', 'sources', 'short'],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
if _AUDIO_MONITOR not in verify.stdout:
|
||
print(f'[AUDIO] ERROR: monitor source "{_AUDIO_MONITOR}" not found!')
|
||
print(f'[AUDIO] Available sources:\n{verify.stdout.strip()}')
|
||
# Tear down the sink we just created since the monitor isn't there
|
||
if _audio_sink_module:
|
||
subprocess.run(['pactl', 'unload-module', _audio_sink_module],
|
||
capture_output=True, timeout=5)
|
||
_audio_sink_module = None
|
||
return
|
||
|
||
# 4. Force this process's audio to the virtual sink
|
||
os.environ['PULSE_SINK'] = _AUDIO_SINK_NAME
|
||
_audio_isolated = True
|
||
print(f'[AUDIO] ✓ Virtual sink "{_AUDIO_SINK_NAME}" active (module {_audio_sink_module})')
|
||
print(f'[AUDIO] ✓ Loopback active (module {_audio_lb_module})')
|
||
print(f'[AUDIO] ✓ PULSE_SINK={_AUDIO_SINK_NAME} — all app audio routed to virtual sink')
|
||
print(f'[AUDIO] ✓ Capture source: {_AUDIO_MONITOR}')
|
||
|
||
|
||
def _teardown_audio_isolation():
|
||
"""Remove the virtual sink (called at process exit via atexit)."""
|
||
global _audio_sink_module, _audio_lb_module, _audio_isolated
|
||
for mod_id in (_audio_lb_module, _audio_sink_module):
|
||
if mod_id:
|
||
try:
|
||
subprocess.run(['pactl', 'unload-module', mod_id],
|
||
capture_output=True, timeout=5)
|
||
except Exception:
|
||
pass
|
||
_audio_sink_module = None
|
||
_audio_lb_module = None
|
||
if _audio_isolated:
|
||
_audio_isolated = False
|
||
os.environ.pop('PULSE_SINK', None)
|
||
print('[AUDIO] Virtual sink removed')
|
||
|
||
|
||
def get_audio_capture_source():
|
||
"""Return the PulseAudio source to capture from.
|
||
|
||
If the virtual sink is active this returns its monitor; otherwise
|
||
falls back to default.monitor (which captures ALL system audio).
|
||
"""
|
||
if _audio_isolated:
|
||
# Double-check the monitor source still exists (PipeWire can be fussy)
|
||
try:
|
||
r = subprocess.run(
|
||
['pactl', 'list', 'sources', 'short'],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
if _AUDIO_MONITOR in r.stdout:
|
||
print(f'[AUDIO] Capturing from isolated source: {_AUDIO_MONITOR}')
|
||
return _AUDIO_MONITOR
|
||
else:
|
||
print(f'[AUDIO] ERROR: {_AUDIO_MONITOR} disappeared! '
|
||
f'Available: {r.stdout.strip()}')
|
||
except Exception as e:
|
||
print(f'[AUDIO] pactl check failed: {e}')
|
||
print('[AUDIO] WARNING: virtual sink not active – capturing ALL system audio!')
|
||
print('[AUDIO] The listener WILL hear all your system audio (YouTube, Spotify, etc).')
|
||
return 'default.monitor'
|
||
|
||
|
||
def _route_qt_audio_to_virtual_sink():
|
||
"""Move any PulseAudio/PipeWire sink-inputs from this process to the
|
||
virtual techdj_stream sink.
|
||
|
||
Needed because Qt6 may output audio via the PipeWire-native backend
|
||
instead of the PulseAudio compatibility layer, which means PULSE_SINK
|
||
has no effect and the monitor source used by the streaming ffmpeg would
|
||
capture silence instead of the DJ's music. Calling pactl move-sink-input
|
||
works regardless of whether the client originally used PulseAudio or
|
||
PipeWire-native, as PipeWire exposes a PulseAudio-compatible socket.
|
||
"""
|
||
if not _audio_isolated:
|
||
return
|
||
pid_str = str(os.getpid())
|
||
try:
|
||
result = subprocess.run(
|
||
['pactl', 'list', 'sink-inputs'],
|
||
capture_output=True, text=True, timeout=3,
|
||
)
|
||
current_id = None
|
||
current_app_pid = None
|
||
for line in result.stdout.splitlines():
|
||
stripped = line.strip()
|
||
if stripped.startswith('Sink Input #'):
|
||
# Flush previous block
|
||
if current_id and current_app_pid == pid_str:
|
||
subprocess.run(
|
||
['pactl', 'move-sink-input', current_id, _AUDIO_SINK_NAME],
|
||
capture_output=True, timeout=3,
|
||
)
|
||
current_id = stripped.split('#')[1].strip()
|
||
current_app_pid = None
|
||
elif 'application.process.id' in stripped and '"' in stripped:
|
||
current_app_pid = stripped.split('"')[1]
|
||
# Handle last block
|
||
if current_id and current_app_pid == pid_str:
|
||
subprocess.run(
|
||
['pactl', 'move-sink-input', current_id, _AUDIO_SINK_NAME],
|
||
capture_output=True, timeout=3,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
atexit.register(_teardown_audio_isolation)
|
||
|
||
|
||
# --- 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:
|
||
# Use a non-blocking timer to poll for the file instead of blocking the GUI thread.
|
||
self._poll_deadline = time.time() + 10.0
|
||
self._poll_timer = QTimer()
|
||
self._poll_timer.setInterval(100)
|
||
self._poll_timer.timeout.connect(self._check_file_ready)
|
||
self._poll_timer.start()
|
||
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}")
|
||
|
||
def _check_file_ready(self):
|
||
"""Non-blocking poll: check if the downloaded file has appeared on disk."""
|
||
if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0:
|
||
self._poll_timer.stop()
|
||
print(f"[DEBUG] Download complete: {self.final_filename} ({os.path.getsize(self.final_filename)} bytes)")
|
||
self.download_finished.emit(self.final_filename)
|
||
elif time.time() > self._poll_deadline:
|
||
self._poll_timer.stop()
|
||
self.error_occurred.emit(f"Download finished but file missing or empty:\n{self.final_filename}")
|
||
|
||
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;
|
||
}
|
||
""")
|
||
|
||
pw_label = QLabel("DJ Panel Password (leave blank if none):")
|
||
self.dj_password_input = QLineEdit()
|
||
self.dj_password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||
self.dj_password_input.setPlaceholderText("Leave blank if no password is set")
|
||
self.dj_password_input.setText(self.audio_settings.get("dj_panel_password", ""))
|
||
self.dj_password_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(8)
|
||
layout.addWidget(pw_label)
|
||
layout.addWidget(self.dj_password_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()
|
||
|
||
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(),
|
||
"dj_panel_password": self.dj_password_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 this app's audio output (isolated) 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, fmt="wav", sample_rate=48000):
|
||
"""Start recording this app's audio to file.
|
||
|
||
fmt -- 'wav' (default) or 'mp3'
|
||
sample_rate -- sample rate in Hz
|
||
"""
|
||
self.output_file = output_path
|
||
|
||
if not shutil.which("ffmpeg"):
|
||
self.recording_error.emit("FFmpeg not found. Install with: sudo apt install ffmpeg")
|
||
return False
|
||
|
||
source = get_audio_capture_source()
|
||
print(f"[RECORDING] Starting: {output_path} format={fmt} sr={sample_rate} source={source}")
|
||
|
||
if fmt == "mp3":
|
||
args = [
|
||
"-f", "pulse",
|
||
"-i", source,
|
||
"-ac", "2",
|
||
"-ar", str(sample_rate),
|
||
"-codec:a", "libmp3lame",
|
||
"-q:a", "2", # VBR ~190 kbps
|
||
"-y",
|
||
output_path,
|
||
]
|
||
else: # wav
|
||
args = [
|
||
"-f", "pulse",
|
||
"-i", source,
|
||
"-ac", "2",
|
||
"-ar", str(sample_rate),
|
||
"-acodec", "pcm_s16le",
|
||
"-sample_fmt", "s16",
|
||
"-y",
|
||
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...")
|
||
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 the currently-playing deck's audio file to the server.
|
||
|
||
Instead of capturing from a PulseAudio monitor (which is unreliable when
|
||
Qt6 uses the PipeWire-native or ALSA audio backend), this worker reads the
|
||
file being played directly through ffmpeg and sends the resulting MP3 bytes
|
||
to the server as audio_chunk events. The server distributes them to all
|
||
connected listener browsers via /stream.mp3.
|
||
|
||
Pacing strategy
|
||
---------------
|
||
We do NOT use ffmpeg's ``-re`` flag because it paces from byte-zero of the
|
||
file which causes a startup surge of pre-seek data right after a ``-ss``
|
||
seek — this surge is what listeners hear as glitchy/stuttery audio at the
|
||
beginning of every track.
|
||
|
||
Instead we use an instantaneous fast-seek (``-ss`` before ``-i``), let
|
||
ffmpeg encode as fast as possible without ``-re``, and manually throttle
|
||
the read loop on our side using a token-bucket controlled by wall-clock
|
||
time. This gives perfectly smooth, stutter-free output with no startup
|
||
burst, and still handles seeks mid-track cleanly.
|
||
"""
|
||
streaming_started = pyqtSignal()
|
||
streaming_error = pyqtSignal(str)
|
||
listener_count = pyqtSignal(int)
|
||
|
||
# Throttle pacing: emit CHUNK_BYTES bytes every CHUNK_INTERVAL seconds.
|
||
# At 128 kbps that is 16 000 bytes/s = 16 bytes/ms.
|
||
# 2048 bytes / 0.128 s ≈ 125 ms cadence — smooth without latency spikes.
|
||
_STREAM_BITRATE_BPS = 128_000 # must match -b:a below
|
||
_CHUNK_BYTES = 2048
|
||
_CHUNK_INTERVAL = _CHUNK_BYTES / (_STREAM_BITRATE_BPS / 8) # seconds
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.sio = None
|
||
self.stream_url = ""
|
||
self.dj_password = ""
|
||
self.glow_intensity = 30 # mirrors the UI slider; sent to listeners on connect
|
||
self.is_running = False
|
||
self.ffmpeg_proc = None
|
||
self._broadcast_started = False
|
||
# Thread-safe command queue: ('play', file_path, position_ms) or ('stop',)
|
||
self._file_cmd_queue = queue.Queue(maxsize=20)
|
||
|
||
def on_connect(self):
|
||
print("[SOCKET] Connected to DJ server")
|
||
if not self._broadcast_started:
|
||
self._broadcast_started = True
|
||
self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'})
|
||
# Push current glow intensity to all listeners as soon as we connect
|
||
self.sio.emit('listener_glow', {'intensity': self.glow_intensity})
|
||
self.streaming_started.emit()
|
||
else:
|
||
# Reconnected mid-stream — server handles resume gracefully
|
||
print("[SOCKET] Reconnected - resuming existing broadcast")
|
||
self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'})
|
||
self.sio.emit('listener_glow', {'intensity': self.glow_intensity})
|
||
|
||
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))
|
||
|
||
@staticmethod
|
||
def _get_auth_cookie(base_url, password):
|
||
"""POST the DJ panel password to /login and return the session cookie string.
|
||
|
||
The server sets a Flask session cookie on success. We forward that
|
||
cookie as an HTTP header on the Socket.IO upgrade request so the
|
||
socket handler sees an authenticated session.
|
||
"""
|
||
try:
|
||
resp = requests.post(
|
||
f"{base_url}/login",
|
||
data={"password": password},
|
||
allow_redirects=False,
|
||
timeout=5,
|
||
)
|
||
# Server responds with 302 + Set-Cookie on success
|
||
if resp.cookies:
|
||
return "; ".join(f"{k}={v}" for k, v in resp.cookies.items())
|
||
print(f"[AUTH] Login response {resp.status_code} — no session cookie returned")
|
||
return None
|
||
except Exception as e:
|
||
print(f"[AUTH] Login request failed: {e}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def _drain_stderr(proc):
|
||
"""Read ffmpeg's stderr in a daemon thread so the OS pipe buffer never
|
||
fills up and deadlocks the stdout read loop."""
|
||
try:
|
||
for raw_line in iter(proc.stderr.readline, b''):
|
||
line = raw_line.decode('utf-8', errors='ignore').strip()
|
||
if line:
|
||
print(f"[STREAM FFMPEG] {line}")
|
||
except Exception:
|
||
pass
|
||
|
||
def run(self):
|
||
try:
|
||
# Create a fresh Socket.IO client for each session
|
||
self.sio = socketio.Client()
|
||
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)
|
||
|
||
# If the DJ panel has a password set, authenticate via HTTP first to
|
||
# obtain a Flask session cookie, then forward it on the WS upgrade
|
||
# request so the socket handler sees an authenticated session.
|
||
connect_headers = {}
|
||
if self.dj_password:
|
||
print("[AUTH] DJ panel password configured — authenticating...")
|
||
cookie = self._get_auth_cookie(self.stream_url, self.dj_password)
|
||
if cookie:
|
||
connect_headers['Cookie'] = cookie
|
||
print("[AUTH] Authenticated — session cookie obtained")
|
||
else:
|
||
self.streaming_error.emit(
|
||
"Authentication failed.\n"
|
||
"Check the DJ panel password in Settings → Audio."
|
||
)
|
||
return
|
||
|
||
# wait_timeout: how long to wait for the server to respond during connect
|
||
self.sio.connect(self.stream_url, wait_timeout=10, headers=connect_headers)
|
||
|
||
print("[STREAM] Connected — waiting for deck to start playing...")
|
||
|
||
# File-based streaming loop.
|
||
# Waits for ('play', path, pos_ms) commands from the main thread,
|
||
# then pipes the file through ffmpeg at real-time rate to the server.
|
||
# This is reliable regardless of Qt's audio backend (PulseAudio /
|
||
# PipeWire-native / ALSA), since we read the file directly.
|
||
current_proc = None
|
||
|
||
while self.is_running:
|
||
# Block briefly waiting for a command; loop allows is_running re-check
|
||
try:
|
||
cmd = self._file_cmd_queue.get(timeout=0.25)
|
||
except queue.Empty:
|
||
# If the current ffmpeg exited on its own (track ended), clean up
|
||
if current_proc is not None and current_proc.poll() is not None:
|
||
current_proc = None
|
||
self.ffmpeg_proc = None
|
||
continue
|
||
|
||
if cmd[0] == 'play':
|
||
# Kill previous ffmpeg before starting a new one
|
||
if current_proc and current_proc.poll() is None:
|
||
current_proc.terminate()
|
||
try:
|
||
current_proc.wait(timeout=1.0)
|
||
except Exception:
|
||
current_proc.kill()
|
||
|
||
_, file_path, position_ms, active_fx = cmd
|
||
position_secs = max(0.0, position_ms / 1000.0)
|
||
af_filter = StreamingWorker._build_af_filter(active_fx)
|
||
print(f"[STREAM] Streaming file: {file_path} from {position_secs:.1f}s fx={active_fx or 'none'}")
|
||
|
||
# -ss before -i = fast (keyframe) seek; no -re so ffmpeg
|
||
# encodes ahead of real-time and we pace the reads ourselves.
|
||
ffmpeg_cmd = [
|
||
"ffmpeg",
|
||
"-hide_banner",
|
||
"-loglevel", "error",
|
||
"-ss", f"{position_secs:.3f}",
|
||
"-i", file_path,
|
||
"-vn",
|
||
"-ac", "2",
|
||
"-ar", "44100",
|
||
]
|
||
if af_filter:
|
||
ffmpeg_cmd.extend(["-af", af_filter])
|
||
ffmpeg_cmd.extend([
|
||
"-b:a", "128k",
|
||
"-write_xing", "0", # no Xing/LAME header — cleaner mid-stream
|
||
"-flush_packets", "1",
|
||
"-f", "mp3",
|
||
"pipe:1",
|
||
])
|
||
current_proc = subprocess.Popen(
|
||
ffmpeg_cmd,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
bufsize=0,
|
||
)
|
||
self.ffmpeg_proc = current_proc
|
||
threading.Thread(
|
||
target=self._drain_stderr, args=(current_proc,), daemon=True
|
||
).start()
|
||
|
||
# Inner read loop — token-bucket pacing so we send at exactly
|
||
# bitrate speed regardless of how fast ffmpeg encodes.
|
||
_interval = StreamingWorker._CHUNK_INTERVAL
|
||
_sz = StreamingWorker._CHUNK_BYTES
|
||
_next_send = time.monotonic()
|
||
|
||
while self.is_running:
|
||
# Non-blocking check for new command (switch track / stop)
|
||
try:
|
||
next_cmd = self._file_cmd_queue.get_nowait()
|
||
# Re-queue so the outer loop handles it
|
||
try:
|
||
self._file_cmd_queue.put_nowait(next_cmd)
|
||
except queue.Full:
|
||
pass
|
||
# Kill current proc so read() below returns immediately
|
||
if current_proc.poll() is None:
|
||
current_proc.terminate()
|
||
break
|
||
except queue.Empty:
|
||
pass
|
||
|
||
if current_proc.poll() is not None:
|
||
current_proc = None
|
||
self.ffmpeg_proc = None
|
||
break
|
||
|
||
chunk = current_proc.stdout.read(_sz)
|
||
if not chunk:
|
||
current_proc = None
|
||
self.ffmpeg_proc = None
|
||
break
|
||
|
||
sio = self.sio
|
||
if sio and sio.connected:
|
||
# Token-bucket: sleep until the next scheduled send slot.
|
||
now = time.monotonic()
|
||
wait = _next_send - now
|
||
if wait > 0:
|
||
time.sleep(wait)
|
||
_next_send = max(time.monotonic(), _next_send) + _interval
|
||
sio.emit('audio_chunk', chunk)
|
||
|
||
elif cmd[0] == 'stop':
|
||
if current_proc and current_proc.poll() is None:
|
||
current_proc.terminate()
|
||
try:
|
||
current_proc.wait(timeout=1.0)
|
||
except Exception:
|
||
current_proc.kill()
|
||
current_proc = None
|
||
self.ffmpeg_proc = None
|
||
|
||
except Exception as e:
|
||
self.streaming_error.emit(f"Streaming error: {e}")
|
||
finally:
|
||
self.stop_streaming()
|
||
|
||
def start_streaming(self, base_url, bitrate=128, password=""):
|
||
self.stream_url = base_url
|
||
self.dj_password = password
|
||
self.is_running = True
|
||
self.start()
|
||
return True
|
||
|
||
def emit_if_connected(self, event, data=None):
|
||
"""Thread-safe emit from any thread — no-op if socket is not connected."""
|
||
sio = self.sio
|
||
if sio and sio.connected:
|
||
try:
|
||
sio.emit(event, data)
|
||
except Exception as e:
|
||
print(f"[SOCKET] emit_if_connected error: {e}")
|
||
|
||
@staticmethod
|
||
def _build_af_filter(effects):
|
||
"""Return an ffmpeg -af filter string from the active effects set, or None."""
|
||
if not effects:
|
||
return None
|
||
segments = []
|
||
for fx_id in ('reverb', 'echo', 'comb'): # only one delay-type at a time
|
||
if fx_id in effects:
|
||
segments.extend(FX_DEFS[fx_id])
|
||
break
|
||
for fx_id in ('flange', 'phase', 'lpf', 'hpf', 'crush', 'bass', 'treble'):
|
||
if fx_id in effects:
|
||
segments.extend(FX_DEFS[fx_id])
|
||
return ','.join(segments) if segments else None
|
||
|
||
def switch_file(self, file_path, position_ms=0, effects=None):
|
||
"""Called from the main thread when a deck starts playing.
|
||
|
||
Signals the streaming loop to kill the current ffmpeg (if any) and
|
||
start a new one reading *file_path* from *position_ms* with the given
|
||
effects filter chain.
|
||
"""
|
||
if not file_path:
|
||
return
|
||
# Drop any stale pending commands so only the latest file matters
|
||
while not self._file_cmd_queue.empty():
|
||
try:
|
||
self._file_cmd_queue.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
try:
|
||
self._file_cmd_queue.put_nowait((
|
||
'play', file_path, int(position_ms), frozenset(effects or [])
|
||
))
|
||
except queue.Full:
|
||
pass
|
||
|
||
def stop_file(self):
|
||
"""Called from the main thread when a deck pauses or stops."""
|
||
while not self._file_cmd_queue.empty():
|
||
try:
|
||
self._file_cmd_queue.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
try:
|
||
self._file_cmd_queue.put_nowait(('stop',))
|
||
except queue.Full:
|
||
pass
|
||
|
||
def stop_streaming(self):
|
||
"""Thread-safe stop: capture refs locally before clearing to avoid TOCTOU."""
|
||
self.is_running = False
|
||
self._broadcast_started = False
|
||
|
||
proc = self.ffmpeg_proc
|
||
self.ffmpeg_proc = None
|
||
sio = self.sio
|
||
self.sio = None
|
||
|
||
if proc:
|
||
try:
|
||
proc.terminate()
|
||
except Exception:
|
||
pass
|
||
|
||
if sio:
|
||
try:
|
||
if sio.connected:
|
||
sio.emit('stop_broadcast')
|
||
sio.disconnect()
|
||
except Exception:
|
||
pass
|
||
|
||
# --- 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
|
||
# 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(140, 140)
|
||
self.angle = 0
|
||
self.speed = 1.0
|
||
self.is_spinning = False
|
||
self.color = QColor(color_hex)
|
||
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 - 4
|
||
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)
|
||
cx, cy = self.center.x(), self.center.y()
|
||
r = self.radius
|
||
|
||
# --- outer tyre rim (metallic silver ring) ---
|
||
p.setBrush(QBrush(QColor("#1a2030")))
|
||
p.setPen(QPen(QColor("#334455"), 1))
|
||
p.drawEllipse(self.center, r, r)
|
||
|
||
# --- main vinyl disk (rotates) ---
|
||
p.save()
|
||
p.translate(self.center)
|
||
p.rotate(self.angle)
|
||
|
||
# Base vinyl – deep black with subtle radial gradient
|
||
vinyl_grad = QRadialGradient(QPointF(0, 0), r * 0.97)
|
||
vinyl_grad.setColorAt(0.0, QColor("#1c1c1c"))
|
||
vinyl_grad.setColorAt(0.45, QColor("#0d0d0d"))
|
||
vinyl_grad.setColorAt(0.75, QColor("#141414"))
|
||
vinyl_grad.setColorAt(1.0, QColor("#0a0a0a"))
|
||
p.setBrush(QBrush(vinyl_grad))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawEllipse(QPointF(0, 0), r * 0.97, r * 0.97)
|
||
|
||
# Groove rings – concentric thin circles
|
||
groove_pen = QPen(QColor("#2a2a2a"), 1)
|
||
groove_pen.setCosmetic(True)
|
||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||
for frac in [0.93, 0.88, 0.82, 0.76, 0.70, 0.65, 0.60, 0.53, 0.47, 0.42]:
|
||
groove_pen.setColor(QColor("#222222") if frac > 0.6 else QColor("#1e1e1e"))
|
||
p.setPen(groove_pen)
|
||
p.drawEllipse(QPointF(0, 0), r * frac, r * frac)
|
||
|
||
# Shiny reflection arc (static highlight on top-left of disk)
|
||
p.restore()
|
||
p.save()
|
||
p.translate(self.center)
|
||
p.rotate(self.angle)
|
||
# thin bright arc
|
||
shine_pen = QPen(QColor(255, 255, 255, 28), 2)
|
||
shine_pen.setCosmetic(True)
|
||
p.setPen(shine_pen)
|
||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||
p.drawArc(QRectF(-r*0.85, -r*0.85, r*1.7, r*1.7), 120*16, 60*16)
|
||
p.restore()
|
||
|
||
# --- center label (rotates with disk) ---
|
||
p.save()
|
||
p.translate(self.center)
|
||
p.rotate(self.angle)
|
||
|
||
# Label background gradient
|
||
label_r = r * 0.34
|
||
label_grad = QRadialGradient(QPointF(-label_r*0.25, -label_r*0.2), label_r)
|
||
lc = self.color
|
||
darker = lc.darker(200)
|
||
label_grad.setColorAt(0.0, lc.lighter(130))
|
||
label_grad.setColorAt(0.5, lc)
|
||
label_grad.setColorAt(1.0, darker)
|
||
p.setBrush(QBrush(label_grad))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawEllipse(QPointF(0, 0), label_r, label_r)
|
||
|
||
# Neon ring around label
|
||
ring_col = QColor(self.color)
|
||
ring_col.setAlpha(200)
|
||
p.setPen(QPen(ring_col, 2))
|
||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||
p.drawEllipse(QPointF(0, 0), label_r + 3, label_r + 3)
|
||
|
||
# Spindle hole
|
||
p.setBrush(QBrush(QColor("#000")))
|
||
p.setPen(QPen(QColor("#1a1a1a"), 1))
|
||
p.drawEllipse(QPointF(0, 0), label_r * 0.12, label_r * 0.12)
|
||
|
||
# Marker stripe on label
|
||
p.setBrush(QBrush(QColor(255, 255, 255, 180)))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawRect(QRectF(-2, -label_r * 0.9, 4, label_r * 0.45))
|
||
|
||
p.restore()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Background workers for waveform loading and BPM detection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class WaveformLoaderThread(QThread):
|
||
"""Loads RMS bars from an audio file in a background thread.
|
||
Safe to emit signals back to the main thread via Qt's queued connection.
|
||
"""
|
||
wave_ready = pyqtSignal(list)
|
||
|
||
def __init__(self, file_path, num_bars=300):
|
||
super().__init__()
|
||
self.file_path = str(file_path)
|
||
self.num_bars = num_bars
|
||
|
||
def run(self):
|
||
try:
|
||
data, _ = sf.read(self.file_path, dtype='float32', always_2d=True)
|
||
mono = data.mean(axis=1)
|
||
chunk = max(1, len(mono) // self.num_bars)
|
||
bars = []
|
||
for i in range(self.num_bars):
|
||
seg = mono[i * chunk:(i + 1) * chunk]
|
||
rms = float(np.sqrt(np.mean(seg ** 2))) if len(seg) else 0.02
|
||
bars.append(max(0.02, rms))
|
||
peak = max(bars) or 1.0
|
||
self.wave_ready.emit([max(0.05, v / peak) for v in bars])
|
||
except Exception as e:
|
||
print(f"[WAVE] Fallback ({Path(self.file_path).name}): {e}")
|
||
random.seed(self.file_path)
|
||
base = [max(0.05, random.random() ** 1.5) for _ in range(self.num_bars)]
|
||
smoothed = []
|
||
for i, v in enumerate(base):
|
||
nb = [base[max(0, i - 1)], v, base[min(len(base) - 1, i + 1)]]
|
||
smoothed.append(sum(nb) / len(nb))
|
||
self.wave_ready.emit(smoothed)
|
||
|
||
|
||
class BPMDetectorThread(QThread):
|
||
"""Detects BPM using librosa in a background thread."""
|
||
bpm_ready = pyqtSignal(float)
|
||
|
||
def __init__(self, file_path_str):
|
||
super().__init__()
|
||
self.file_path = file_path_str
|
||
|
||
def run(self):
|
||
if not HAS_LIBROSA:
|
||
return
|
||
try:
|
||
y, sr = librosa.load(self.file_path, sr=None, mono=True, duration=60)
|
||
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
|
||
bpm_val = float(np.atleast_1d(tempo)[0])
|
||
if 60 <= bpm_val <= 200:
|
||
self.bpm_ready.emit(round(bpm_val, 1))
|
||
print(f"[BPM] {bpm_val:.1f} BPM — {Path(self.file_path).name}")
|
||
else:
|
||
print(f"[BPM] Out-of-range ({bpm_val:.1f}), ignored")
|
||
except Exception as e:
|
||
print(f"[BPM] Detection failed: {e}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Real-time spectrum level meter
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class LevelMeterWidget(QWidget):
|
||
"""Animated spectrum bar meter driven by pre-analysed waveform data."""
|
||
NUM_BARS = 32
|
||
DECAY = 0.055
|
||
PEAK_HOLD_MAX = 18 # frames to hold peak marker before decay
|
||
|
||
def __init__(self, color_hex, parent=None):
|
||
super().__init__(parent)
|
||
self.base_color = QColor(color_hex)
|
||
self.setFixedHeight(52)
|
||
|
||
self._levels = [0.0] * self.NUM_BARS
|
||
self._peaks = [0.0] * self.NUM_BARS
|
||
self._peak_hold = [0] * self.NUM_BARS
|
||
self._wave_data = []
|
||
self._pos = 0
|
||
self._dur = 1
|
||
self._playing = False
|
||
self._phase = 0 # pseudo-random animation phase
|
||
|
||
self._timer = QTimer(self)
|
||
self._timer.setInterval(33) # ~30 fps
|
||
self._timer.timeout.connect(self._tick)
|
||
self._timer.start()
|
||
|
||
def set_wave_data(self, data):
|
||
self._wave_data = data
|
||
|
||
def update_playback(self, pos, dur, playing):
|
||
self._pos = pos
|
||
self._dur = max(1, dur)
|
||
self._playing = playing
|
||
|
||
def _tick(self):
|
||
self._phase = (self._phase + 1) % 97
|
||
|
||
# Skip expensive repaint when nothing is animating
|
||
if not self._playing and max(self._levels) < 0.005 and max(self._peaks) < 0.005:
|
||
return
|
||
|
||
if self._playing and self._wave_data:
|
||
ratio = self._pos / self._dur
|
||
n = len(self._wave_data)
|
||
ci = int(ratio * n)
|
||
# How many wave-data bars to spread across the NUM_BARS display
|
||
window = max(4, n // 20)
|
||
for i in range(self.NUM_BARS):
|
||
spread = int((i / self.NUM_BARS - 0.5) * window * 0.5)
|
||
idx = max(0, min(n - 1, ci + spread))
|
||
# Per-bar pseudo-random variation so bars look like separate bands
|
||
variation = 0.65 + 0.70 * ((i * 7919 + self._phase * 31) % 100) / 100.0
|
||
target = min(1.0, self._wave_data[idx] * variation)
|
||
if target > self._levels[i]:
|
||
self._levels[i] = target
|
||
|
||
decay = self.DECAY * (1.0 if self._playing else 2.5)
|
||
for i in range(self.NUM_BARS):
|
||
self._levels[i] = max(0.0, self._levels[i] - decay)
|
||
if self._levels[i] >= self._peaks[i]:
|
||
self._peaks[i] = self._levels[i]
|
||
self._peak_hold[i] = self.PEAK_HOLD_MAX
|
||
elif self._peak_hold[i] > 0:
|
||
self._peak_hold[i] -= 1
|
||
else:
|
||
self._peaks[i] = max(0.0, self._peaks[i] - self.DECAY * 0.4)
|
||
|
||
self.update()
|
||
|
||
def paintEvent(self, event):
|
||
painter = QPainter(self)
|
||
w, h = self.width(), self.height()
|
||
painter.fillRect(0, 0, w, h, QColor(4, 7, 14))
|
||
|
||
bar_w = w / self.NUM_BARS
|
||
gap = max(1, int(bar_w * 0.20))
|
||
bw = max(2, int(bar_w) - gap)
|
||
|
||
c_bot = self.base_color
|
||
c_top = QColor(255, 50, 50)
|
||
|
||
t_top = int(h * 0.18) # top 18% = red zone
|
||
|
||
for i in range(self.NUM_BARS):
|
||
lvl = self._levels[i]
|
||
bx = int(i * bar_w)
|
||
bah = max(2, int(lvl * (h - 2)))
|
||
if bah < 2:
|
||
continue
|
||
by = h - bah
|
||
|
||
if by < t_top:
|
||
painter.fillRect(bx, by, bw, t_top - by, c_top)
|
||
painter.fillRect(bx, t_top, bw, h - t_top, c_bot)
|
||
else:
|
||
painter.fillRect(bx, by, bw, h - by, c_bot)
|
||
|
||
# Peak hold marker
|
||
pk = self._peaks[i]
|
||
if pk > 0.05:
|
||
py = h - int(pk * (h - 2)) - 2
|
||
painter.fillRect(bx, py, bw, 2, QColor(255, 255, 255, 190))
|
||
|
||
painter.end()
|
||
|
||
|
||
class WaveformWidget(QWidget):
|
||
seekRequested = pyqtSignal(int)
|
||
bpm_detected = pyqtSignal(float) # kept for back-compat; see BPMDetectorThread
|
||
wave_ready = pyqtSignal(list) # emitted when waveform bars are loaded
|
||
|
||
def __init__(self, color_hex, parent=None):
|
||
super().__init__(parent)
|
||
self.color = QColor(color_hex)
|
||
self.setMinimumHeight(70)
|
||
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
|
||
self._wave_loader = None # keep reference so thread isn't GC'd
|
||
|
||
def detect_bpm(self, file_path_str):
|
||
"""Start BPM detection in a QThread; result forwarded via bpm_detected signal."""
|
||
if not HAS_LIBROSA:
|
||
return
|
||
# Disconnect any previous thread so a stale BPM result from a slow-finishing
|
||
# old track does not overwrite the BPM of the newly loaded track.
|
||
if hasattr(self, '_bpm_thread') and self._bpm_thread is not None:
|
||
try:
|
||
self._bpm_thread.bpm_ready.disconnect()
|
||
except RuntimeError:
|
||
pass
|
||
self._bpm_thread = BPMDetectorThread(file_path_str)
|
||
self._bpm_thread.bpm_ready.connect(self.bpm_detected)
|
||
self._bpm_thread.start()
|
||
|
||
def generate_wave(self, file_path):
|
||
"""Kick off background waveform loading; shows blank until ready."""
|
||
self.wave_data = []
|
||
self.update()
|
||
if self._wave_loader is not None:
|
||
# Disconnect signal before replacing so a slow-finishing old thread
|
||
# cannot overwrite the new waveform with stale data.
|
||
try:
|
||
self._wave_loader.wave_ready.disconnect(self._on_wave_ready)
|
||
except RuntimeError:
|
||
pass
|
||
if self._wave_loader.isRunning():
|
||
self._wave_loader.quit()
|
||
self._wave_loader = WaveformLoaderThread(file_path)
|
||
self._wave_loader.wave_ready.connect(self._on_wave_ready)
|
||
self._wave_loader.start()
|
||
|
||
def _on_wave_ready(self, bars):
|
||
self.wave_data = bars
|
||
self.wave_ready.emit(bars)
|
||
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 e.buttons() and 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()
|
||
mid = h / 2
|
||
|
||
# Background with scanline effect
|
||
p.fillRect(0, 0, w, h, QColor("#05080f"))
|
||
scan_pen = QPen(QColor(0, 0, 0, 30), 1)
|
||
p.setPen(scan_pen)
|
||
for y in range(0, h, 3):
|
||
p.drawLine(0, y, w, y)
|
||
|
||
if not self.wave_data:
|
||
p.setPen(QPen(QColor(self.color), 1))
|
||
p.drawLine(0, int(mid), w, int(mid))
|
||
return
|
||
|
||
bar_w = w / len(self.wave_data)
|
||
play_x = (self.position / self.duration) * w
|
||
|
||
# Played-through color (dimmed version) vs upcoming (bright)
|
||
col_played = self.color.darker(250)
|
||
col_upcoming = self.color
|
||
|
||
# Loop region shading
|
||
if self.loop_active:
|
||
lx = int((self.loop_start / self.duration) * w)
|
||
lw_ = int(((self.loop_end - self.loop_start) / self.duration) * w)
|
||
loop_bg = QColor(255, 165, 0, 35)
|
||
p.fillRect(lx, 0, lw_, h, loop_bg)
|
||
p.setPen(QPen(QColor("#ff9900"), 1))
|
||
p.drawLine(lx, 0, lx, h)
|
||
p.drawLine(lx + lw_, 0, lx + lw_, h)
|
||
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
for i, val in enumerate(self.wave_data):
|
||
bx = i * bar_w
|
||
# Dual-sided: bars grow up and down from center
|
||
bar_half = val * mid * 0.88
|
||
color = col_played if bx < play_x else col_upcoming
|
||
p.setBrush(QBrush(color))
|
||
# upper half
|
||
p.drawRect(QRectF(bx, mid - bar_half, max(1, bar_w - 0.5), bar_half))
|
||
# lower half (mirror)
|
||
dimmed = QColor(color)
|
||
dimmed.setAlpha(120)
|
||
p.setBrush(QBrush(dimmed))
|
||
p.drawRect(QRectF(bx, mid, max(1, bar_w - 0.5), bar_half))
|
||
|
||
# Center line
|
||
p.setPen(QPen(QColor(80, 100, 120, 140), 1))
|
||
p.drawLine(0, int(mid), w, int(mid))
|
||
|
||
# Glowing playhead
|
||
px = int(play_x)
|
||
for offset, alpha_ in [(3, 25), (2, 55), (1, 120), (0, 255)]:
|
||
glow_color = QColor(self.color)
|
||
glow_color.setAlpha(alpha_)
|
||
p.setPen(QPen(glow_color, 1 + offset))
|
||
p.drawLine(px, 0, px, h)
|
||
|
||
# Time marker triangle at bottom
|
||
tri_size = 5
|
||
tri_col = QColor(self.color)
|
||
tri_col.setAlpha(200)
|
||
p.setBrush(QBrush(tri_col))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
tri = QPolygonF([
|
||
QPointF(px - tri_size, h),
|
||
QPointF(px + tri_size, h),
|
||
QPointF(px, h - tri_size * 1.5),
|
||
])
|
||
p.drawPolygon(tri)
|
||
|
||
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._loop_in_pending = None # IN marker set, waiting for OUT
|
||
self._roll_ref_pos = None # position when ROLL was pressed
|
||
self._roll_start_time = None # wall-clock time when ROLL was pressed (for accurate elapsed)
|
||
self.detected_bpm = None # librosa-detected BPM; None = use DEFAULT_BPM
|
||
self.xf_vol = 100
|
||
self.current_title = ""
|
||
self.current_file_path = None
|
||
|
||
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(4)
|
||
|
||
# ── Top row: Vinyl + track info ──────────────────────────────────
|
||
r1 = QHBoxLayout()
|
||
r1.setSpacing(8)
|
||
|
||
self.vinyl = VinylWidget(self.color_code)
|
||
self.vinyl.setFixedSize(150, 150)
|
||
r1.addWidget(self.vinyl)
|
||
|
||
c1 = QVBoxLayout()
|
||
c1.setSpacing(3)
|
||
|
||
# Track title display
|
||
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}44; "
|
||
f"background: #060a10; padding: 5px 6px; font-family: 'Courier New'; "
|
||
f"font-size: 12px; font-weight: bold; letter-spacing: 1px;"
|
||
)
|
||
self.lbl_tr.setWordWrap(True)
|
||
self.lbl_tr.setFixedHeight(46)
|
||
c1.addWidget(self.lbl_tr)
|
||
|
||
# Time row
|
||
rt = QHBoxLayout()
|
||
self.lbl_cur = QLabel("00:00")
|
||
self.lbl_cur.setStyleSheet(
|
||
f"color: {self.color_code}; font-family: 'Courier New'; font-size: 20px; font-weight: bold;"
|
||
)
|
||
self.lbl_tot = QLabel("/ 00:00")
|
||
self.lbl_tot.setStyleSheet("color:#445566; font-family: 'Courier New'; font-size: 13px;")
|
||
rt.addWidget(self.lbl_cur)
|
||
rt.addStretch()
|
||
rt.addWidget(self.lbl_tot)
|
||
c1.addLayout(rt)
|
||
|
||
# BPM + pitch readout row
|
||
bpm_row = QHBoxLayout()
|
||
bpm_label = QLabel("BPM")
|
||
bpm_label.setStyleSheet("color:#334455; font-size:9px; font-family:'Courier New';")
|
||
self.lbl_bpm = QLabel(f"{DEFAULT_BPM}")
|
||
self.lbl_bpm.setStyleSheet(
|
||
"color:#ff9900; font-family:'Courier New'; font-size:16px; font-weight:bold;"
|
||
)
|
||
self.lbl_rate = QLabel("1.00x")
|
||
self.lbl_rate.setStyleSheet(
|
||
f"color:{self.color_code}; font-family:'Courier New'; font-size:13px; font-weight:bold;"
|
||
)
|
||
bpm_row.addWidget(bpm_label)
|
||
bpm_row.addWidget(self.lbl_bpm)
|
||
bpm_row.addStretch()
|
||
bpm_row.addWidget(self.lbl_rate)
|
||
c1.addLayout(bpm_row)
|
||
|
||
# Hot cue buttons row
|
||
hc_row = QHBoxLayout()
|
||
hc_row.setSpacing(3)
|
||
self.hot_cues = {} # idx -> ms position (-1 = unset)
|
||
hc_names = ["CUE1", "CUE2", "CUE3", "CUE4"]
|
||
hc_ids = ["btn_hotcue1", "btn_hotcue2", "btn_hotcue3", "btn_hotcue4"]
|
||
hc_colors = ["#9933ff", "#00ccbb", "#ff7700", "#ff3388"]
|
||
for i in range(4):
|
||
btn = QPushButton(hc_names[i])
|
||
btn.setObjectName(hc_ids[i])
|
||
btn.setCheckable(True)
|
||
btn.setToolTip(f"Hot cue {i+1}: unset — click to set")
|
||
btn.clicked.connect(lambda checked, idx=i: self._hotcue_action(idx))
|
||
self.hot_cues[i] = -1
|
||
hc_row.addWidget(btn)
|
||
setattr(self, f"btn_hc{i+1}", btn)
|
||
c1.addLayout(hc_row)
|
||
|
||
r1.addLayout(c1, 1)
|
||
layout.addLayout(r1)
|
||
|
||
# ── Waveform ──────────────────────────────────────────────────────
|
||
self.wave = WaveformWidget(self.color_code)
|
||
self.wave.seekRequested.connect(self.player.setPosition)
|
||
self.wave.bpm_detected.connect(self._on_bpm_detected)
|
||
layout.addWidget(self.wave)
|
||
|
||
# ── Level meter (spectrum bars) ───────────────────────────────────
|
||
self.level_meter = LevelMeterWidget(self.color_code)
|
||
self.wave.wave_ready.connect(self.level_meter.set_wave_data)
|
||
layout.addWidget(self.level_meter)
|
||
|
||
# ── Loop beat buttons ─────────────────────────────────────────────
|
||
loop_frame = QWidget()
|
||
loop_frame.setStyleSheet("background:#030608; border-radius:3px;")
|
||
g = QGridLayout(loop_frame)
|
||
g.setSpacing(2)
|
||
g.setContentsMargins(4, 3, 4, 3)
|
||
|
||
loop_lbl = QLabel("LOOP")
|
||
loop_lbl.setStyleSheet("color:#334455; font-size:9px; font-family:'Courier New'; letter-spacing:2px;")
|
||
g.addWidget(loop_lbl, 0, 0)
|
||
|
||
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.setFixedHeight(22)
|
||
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 + 1)
|
||
self.loop_btns.append(btn)
|
||
|
||
exit_btn = QPushButton("EXIT")
|
||
exit_btn.setObjectName("btn_loop_exit")
|
||
exit_btn.setFixedHeight(22)
|
||
exit_btn.setToolTip("Clear active loop")
|
||
exit_btn.clicked.connect(self.clear_loop)
|
||
g.addWidget(exit_btn, 0, len(loops) + 1)
|
||
|
||
# ── Row 2: manual IN/OUT + resize + ROLL ──────────────────────────
|
||
self._alo_base_ss = (
|
||
"QPushButton{background:#080d14;color:#446655;border:1px solid #1a2a1a;"
|
||
"font-family:'Courier New';font-size:9px;font-weight:bold;letter-spacing:1px;}"
|
||
"QPushButton:hover{color:#00ffaa;border-color:#00ffaa66;background:#0a1410;}"
|
||
"QPushButton:checked{background:#00ffaa18;color:#00ffaa;border:1px solid #00ffaa;}"
|
||
)
|
||
_alo_ss = self._alo_base_ss
|
||
_alo_lbl = QLabel("AUTO")
|
||
_alo_lbl.setStyleSheet("color:#334455;font-size:9px;font-family:'Courier New';letter-spacing:2px;")
|
||
g.addWidget(_alo_lbl, 1, 0)
|
||
|
||
self.btn_loop_in = QPushButton("IN")
|
||
self.btn_loop_in.setFixedHeight(22)
|
||
self.btn_loop_in.setStyleSheet(_alo_ss)
|
||
self.btn_loop_in.setToolTip("Set loop IN point at current position")
|
||
self.btn_loop_in.clicked.connect(self.loop_set_in)
|
||
g.addWidget(self.btn_loop_in, 1, 1)
|
||
|
||
self.btn_loop_out = QPushButton("OUT")
|
||
self.btn_loop_out.setCheckable(True)
|
||
self.btn_loop_out.setFixedHeight(22)
|
||
self.btn_loop_out.setStyleSheet(_alo_ss)
|
||
self.btn_loop_out.setToolTip("Set loop OUT point at current position and activate loop")
|
||
self.btn_loop_out.clicked.connect(self.loop_set_out)
|
||
g.addWidget(self.btn_loop_out, 1, 2)
|
||
|
||
self.btn_loop_x2 = QPushButton("2X")
|
||
self.btn_loop_x2.setFixedHeight(22)
|
||
self.btn_loop_x2.setStyleSheet(_alo_ss)
|
||
self.btn_loop_x2.setToolTip("Double loop length")
|
||
self.btn_loop_x2.clicked.connect(self.loop_double)
|
||
g.addWidget(self.btn_loop_x2, 1, 3)
|
||
|
||
self.btn_loop_d2 = QPushButton("/2")
|
||
self.btn_loop_d2.setFixedHeight(22)
|
||
self.btn_loop_d2.setStyleSheet(_alo_ss)
|
||
self.btn_loop_d2.setToolTip("Halve loop length")
|
||
self.btn_loop_d2.clicked.connect(self.loop_halve)
|
||
g.addWidget(self.btn_loop_d2, 1, 4)
|
||
|
||
# ROLL: hold to loop, release resumes from where track would have been
|
||
_roll_ss = (
|
||
"QPushButton{background:#0a0514;color:#9933ff;border:1px solid #2a0066;"
|
||
"font-family:'Courier New';font-size:9px;font-weight:bold;letter-spacing:1px;}"
|
||
"QPushButton:hover{color:#cc66ff;border-color:#aa33ff;}"
|
||
"QPushButton:pressed{background:#aa33ff;color:#000;}"
|
||
)
|
||
self.btn_loop_roll = QPushButton("ROLL")
|
||
self.btn_loop_roll.setFixedHeight(22)
|
||
self.btn_loop_roll.setStyleSheet(_roll_ss)
|
||
self.btn_loop_roll.setToolTip("Loop roll: hold loops, release continues from real position")
|
||
self.btn_loop_roll.pressed.connect(self._roll_start)
|
||
self.btn_loop_roll.released.connect(self._roll_end)
|
||
g.addWidget(self.btn_loop_roll, 1, 5)
|
||
|
||
layout.addWidget(loop_frame)
|
||
|
||
# ── Transport controls ────────────────────────────────────────────
|
||
rc = QHBoxLayout()
|
||
rc.setSpacing(4)
|
||
|
||
bp = QPushButton("PLAY")
|
||
bp.setObjectName("btn_play")
|
||
bp.setToolTip("Play track (Space)")
|
||
bp.clicked.connect(self.play)
|
||
|
||
bpa = QPushButton("PAUSE")
|
||
bpa.setObjectName("btn_pause")
|
||
bpa.setToolTip("Pause playback")
|
||
bpa.clicked.connect(self.pause)
|
||
|
||
bs = QPushButton("STOP")
|
||
bs.setObjectName("btn_stop")
|
||
bs.setToolTip("Stop playback")
|
||
bs.clicked.connect(self.stop)
|
||
|
||
# CUE button
|
||
self.btn_cue = QPushButton("CUE")
|
||
self.btn_cue.setObjectName("btn_cue")
|
||
self.btn_cue.setToolTip("Set/jump to cue point")
|
||
self.btn_cue.setFixedWidth(52)
|
||
self._cue_point = -1
|
||
self.btn_cue.clicked.connect(self._cue_action)
|
||
|
||
self.b_mode = QPushButton("CONT")
|
||
self.b_mode.setFixedWidth(90)
|
||
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(6)
|
||
rc.addWidget(self.btn_cue)
|
||
rc.addStretch()
|
||
rc.addWidget(self.b_mode)
|
||
layout.addLayout(rc)
|
||
|
||
# ── Pitch / speed control ─────────────────────────────────────────
|
||
rp = QHBoxLayout()
|
||
rp.setSpacing(4)
|
||
|
||
pitch_lbl = QLabel("PITCH")
|
||
pitch_lbl.setStyleSheet("color:#334455; font-size:9px; font-family:'Courier New'; letter-spacing:1px;")
|
||
pitch_lbl.setFixedWidth(38)
|
||
|
||
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 (50%-150%)")
|
||
self.sl_rate.valueChanged.connect(self.update_playback_rate)
|
||
|
||
br = QPushButton("0")
|
||
br.setToolTip("Reset pitch to 1.0x")
|
||
br.setFixedWidth(24)
|
||
br.setStyleSheet("font-size:10px; padding:2px;")
|
||
br.clicked.connect(lambda: self.sl_rate.setValue(100))
|
||
|
||
rp.addWidget(pitch_lbl)
|
||
rp.addWidget(self.sl_rate)
|
||
rp.addWidget(br)
|
||
rp.addWidget(self.lbl_rate)
|
||
layout.addLayout(rp)
|
||
|
||
# ── Bottom section: Queue + EQ faders ────────────────────────────
|
||
bottom = QHBoxLayout()
|
||
bottom.setSpacing(6)
|
||
|
||
# Queue widget
|
||
qc = QWidget()
|
||
ql = QVBoxLayout(qc)
|
||
ql.setContentsMargins(0, 0, 0, 0)
|
||
ql.setSpacing(2)
|
||
|
||
hq = QHBoxLayout()
|
||
queue_title_lbl = QLabel(f"QUEUE {self.deck_id}")
|
||
queue_title_lbl.setStyleSheet(
|
||
f"font-size:9px; font-family:'Courier New'; letter-spacing:2px; color:{self.color_code}88;"
|
||
)
|
||
bd = QPushButton("X")
|
||
bd.setObjectName("btn_remove")
|
||
bd.setToolTip("Remove selected track from queue")
|
||
bd.setFixedSize(22, 22)
|
||
bd.clicked.connect(self.delete_selected)
|
||
hq.addWidget(queue_title_lbl)
|
||
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(self._queue_double_clicked)
|
||
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)
|
||
sl.setSpacing(0)
|
||
|
||
# EQ label and kill buttons
|
||
eq_header = QHBoxLayout()
|
||
eq_lbl = QLabel("EQ / MIX")
|
||
eq_lbl.setStyleSheet("font-size:9px; font-family:'Courier New'; letter-spacing:2px; color:#334455;")
|
||
eq_header.addWidget(eq_lbl)
|
||
sl.addLayout(eq_header)
|
||
|
||
row_s = QHBoxLayout()
|
||
row_s.setSpacing(4)
|
||
|
||
def make_slider(prop, label, tooltip, color_override=None):
|
||
v = QVBoxLayout()
|
||
v.setSpacing(1)
|
||
s = QSlider(Qt.Orientation.Vertical)
|
||
s.setRange(0, MAX_SLIDER_VALUE)
|
||
s.setValue(MAX_SLIDER_VALUE)
|
||
s.setProperty("eq", prop)
|
||
s.setFixedWidth(18)
|
||
s.setMinimumHeight(70)
|
||
s.setToolTip(tooltip)
|
||
lc = color_override or "#778899"
|
||
l = QLabel(label)
|
||
l.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
l.setStyleSheet(f"font-size:8px; font-family:'Courier New'; color:{lc}; letter-spacing:1px;")
|
||
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", "#aabbcc")
|
||
self.sl_vol.valueChanged.connect(self.recalc_vol)
|
||
self.sl_hi = make_slider("high", "HI", "High EQ (treble) — visual trim only", "#00e5ff")
|
||
self.sl_mid = make_slider("mid", "MID", "Mid EQ — visual trim only", "#00ff88")
|
||
self.sl_low = make_slider("low", "LO", "Low EQ (bass) — visual trim only", "#ff3344")
|
||
# Apply initial volume so audio_output is in the correct state immediately
|
||
self.recalc_vol()
|
||
|
||
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 _on_bpm_detected(self, bpm_val):
|
||
"""Received from waveform worker thread; update BPM display."""
|
||
self.detected_bpm = bpm_val
|
||
effective = bpm_val * (self.sl_rate.value() / 100.0)
|
||
self.lbl_bpm.setText(str(int(round(effective))))
|
||
|
||
def _hotcue_action(self, idx):
|
||
"""Set or jump-to hot cue. Shift-click to clear."""
|
||
mods = QApplication.keyboardModifiers()
|
||
if mods & Qt.KeyboardModifier.ShiftModifier:
|
||
# Clear
|
||
self.hot_cues[idx] = -1
|
||
btn = getattr(self, f"btn_hc{idx+1}")
|
||
btn.setChecked(False)
|
||
btn.setToolTip(f"Hot cue {idx+1}: unset - click to set")
|
||
elif self.hot_cues[idx] == -1:
|
||
# Set at current position
|
||
pos = self.player.position()
|
||
self.hot_cues[idx] = pos
|
||
btn = getattr(self, f"btn_hc{idx+1}")
|
||
btn.setChecked(True)
|
||
m, s = divmod(pos // 1000, 60)
|
||
btn.setToolTip(f"Hot cue {idx+1}: {m:02d}:{s:02d} - Shift+click to clear")
|
||
else:
|
||
# Jump to stored position
|
||
self.player.setPosition(self.hot_cues[idx])
|
||
if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState:
|
||
self.play()
|
||
|
||
def _cue_action(self):
|
||
"""CUE: if stopped/paused set cue point; if playing jump to cue point."""
|
||
state = self.player.playbackState()
|
||
if state == QMediaPlayer.PlaybackState.PlayingState:
|
||
if self._cue_point >= 0:
|
||
self.player.setPosition(self._cue_point)
|
||
else:
|
||
self._cue_point = self.player.position()
|
||
pos = self._cue_point
|
||
m, s = divmod(pos // 1000, 60)
|
||
self.btn_cue.setToolTip(f"CUE point: {m:02d}:{s:02d}")
|
||
|
||
# ── Queue / list helpers ───────────────────────────────────────────
|
||
|
||
def _queue_double_clicked(self, item):
|
||
"""Double-click on a queue item: load it into the deck immediately and remove it."""
|
||
path = item.data(Qt.ItemDataRole.UserRole)
|
||
row = self.q_list.row(item)
|
||
self.q_list.takeItem(row)
|
||
if path:
|
||
self.load_track(path)
|
||
self.play()
|
||
|
||
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: "LOOP1", 2: "STOP"}
|
||
self.b_mode.setText(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 _effective_bpm(self):
|
||
"""Return the current effective BPM (detected or default) adjusted for pitch."""
|
||
base = self.detected_bpm if self.detected_bpm else DEFAULT_BPM
|
||
rate = self.sl_rate.value() / 100.0
|
||
return base * rate if rate > 0 else base
|
||
|
||
def set_loop(self, beats, btn):
|
||
for x in self.loop_btns:
|
||
x.setChecked(x == btn)
|
||
|
||
if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState:
|
||
# Uncheck all — loop can't be set while stopped/paused
|
||
for x in self.loop_btns:
|
||
x.setChecked(False)
|
||
return
|
||
|
||
effective_bpm = self._effective_bpm()
|
||
ms_per_beat = MS_PER_MINUTE / (effective_bpm if effective_bpm > 0 else 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)
|
||
if hasattr(self, 'btn_loop_out'):
|
||
self.btn_loop_out.setChecked(False)
|
||
if hasattr(self, 'btn_loop_in') and hasattr(self, '_alo_base_ss'):
|
||
self.btn_loop_in.setStyleSheet(self._alo_base_ss)
|
||
self._loop_in_pending = None
|
||
|
||
# ── Auto-loop controls ───────────────────────────────────────────────
|
||
|
||
def loop_set_in(self):
|
||
"""Mark current playhead position as loop IN point."""
|
||
self._loop_in_pending = self.player.position()
|
||
# Always apply from the base style so repeated IN clicks work correctly
|
||
self.btn_loop_in.setStyleSheet(
|
||
self._alo_base_ss.replace('color:#446655', 'color:#00ffaa')
|
||
)
|
||
|
||
def loop_set_out(self, checked):
|
||
"""Mark current position as loop OUT and activate (or deactivate) the loop."""
|
||
if not checked:
|
||
self.clear_loop() # also resets _loop_in_pending and btn_loop_in style
|
||
return
|
||
pos = self.player.position()
|
||
start = self._loop_in_pending if self._loop_in_pending is not None else max(0, pos - 2000)
|
||
if pos <= start:
|
||
self.btn_loop_out.setChecked(False)
|
||
return
|
||
self.loop_start = start
|
||
self.loop_end = pos
|
||
self.loop_active = True
|
||
self.loop_timer.start()
|
||
self.wave.set_loop_region(True, self.loop_start, self.loop_end)
|
||
self._loop_in_pending = None
|
||
|
||
def loop_double(self):
|
||
"""Double the current loop length."""
|
||
if not self.loop_active:
|
||
return
|
||
self.loop_end = self.loop_start + (self.loop_end - self.loop_start) * 2
|
||
self.wave.set_loop_region(True, self.loop_start, self.loop_end)
|
||
|
||
def loop_halve(self):
|
||
"""Halve the current loop length (minimum 10 ms)."""
|
||
if not self.loop_active:
|
||
return
|
||
half = (self.loop_end - self.loop_start) // 2
|
||
if half < 10:
|
||
return
|
||
self.loop_end = self.loop_start + half
|
||
# If playhead is already past the new end, snap it back
|
||
if self.player.position() > self.loop_end:
|
||
self.player.setPosition(int(self.loop_start))
|
||
self.wave.set_loop_region(True, self.loop_start, self.loop_end)
|
||
|
||
def _roll_start(self):
|
||
"""Loop roll start: lock loop at current 1-beat length, save real position."""
|
||
ms_per_beat = MS_PER_MINUTE / (self._effective_bpm() or DEFAULT_BPM)
|
||
self._roll_ref_pos = self.player.position()
|
||
self._roll_start_time = time.time()
|
||
self.loop_start = self._roll_ref_pos
|
||
self.loop_end = self._roll_ref_pos + int(ms_per_beat)
|
||
self.loop_active = True
|
||
self.loop_timer.start()
|
||
self.wave.set_loop_region(True, self.loop_start, self.loop_end)
|
||
|
||
def _roll_end(self):
|
||
"""Loop roll end: clear loop, resume from where track would have been."""
|
||
if self._roll_ref_pos is None:
|
||
self.clear_loop()
|
||
return
|
||
# The player position loops during the roll, so we use wall-clock elapsed
|
||
# time to calculate where the track should actually be
|
||
elapsed_ms = int((time.time() - self._roll_start_time) * 1000) if self._roll_start_time else 0
|
||
raw_pos = self._roll_ref_pos + elapsed_ms
|
||
# Clamp to track duration so we never seek past the end and trigger an
|
||
# unexpected EndOfMedia / auto-advance.
|
||
duration = self.real_duration if self.real_duration > 0 else (self.player.duration() or 0)
|
||
real_pos = min(raw_pos, max(0, duration - 100)) if duration > 0 else raw_pos
|
||
self._roll_ref_pos = None
|
||
self._roll_start_time = None
|
||
self.clear_loop()
|
||
if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
||
self.player.setPosition(int(real_pos))
|
||
|
||
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.current_title = p.stem
|
||
self.current_file_path = str(p.absolute())
|
||
self.lbl_tr.setText(p.stem.upper())
|
||
self.vinyl.set_speed(0)
|
||
self.vinyl.angle = 0
|
||
self.vinyl.update()
|
||
|
||
# Clear hot cues from the previous track
|
||
for idx in range(4):
|
||
self.hot_cues[idx] = -1
|
||
btn = getattr(self, f"btn_hc{idx+1}")
|
||
btn.setChecked(False)
|
||
btn.setToolTip(f"Hot cue {idx+1}: unset - click to set")
|
||
# Reset CUE point
|
||
self._cue_point = -1
|
||
self.btn_cue.setToolTip("Set/jump to cue point")
|
||
|
||
self.wave.generate_wave(p)
|
||
# Kick off BPM detection in background (updates lbl_bpm via signal)
|
||
self.detected_bpm = None
|
||
self.lbl_bpm.setText(str(DEFAULT_BPM))
|
||
self.wave.detect_bpm(str(p.absolute()))
|
||
# 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:
|
||
# Premature EndOfMedia can occur with VBR MP3s (GStreamer) or after a
|
||
# setSource() on some Qt6 builds. If we know the real duration and the
|
||
# player is nowhere near the end, ignore it entirely instead of stopping.
|
||
if self.real_duration > 0 and self.player.position() < self.real_duration - 1000:
|
||
print(f"[DEBUG] {self.deck_id} Premature EndOfMedia at {self.player.position()}ms "
|
||
f"(expected {self.real_duration}ms) — ignoring")
|
||
return
|
||
|
||
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 _emit_playing_state(self, is_playing):
|
||
"""Emit deck_glow and now_playing events to the listener page."""
|
||
mw = self.window()
|
||
if not hasattr(mw, 'streaming_worker') or not hasattr(mw, 'deck_a') or not hasattr(mw, 'deck_b'):
|
||
return
|
||
other = mw.deck_b if self.deck_id == 'A' else mw.deck_a
|
||
other_playing = (other.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState)
|
||
a_playing = is_playing if self.deck_id == 'A' else other_playing
|
||
b_playing = is_playing if self.deck_id == 'B' else other_playing
|
||
mw.streaming_worker.emit_if_connected('deck_glow', {'A': a_playing, 'B': b_playing})
|
||
if is_playing and self.current_title:
|
||
mw.streaming_worker.emit_if_connected('now_playing', {
|
||
'title': self.current_title,
|
||
'deck': self.deck_id,
|
||
})
|
||
|
||
def play(self):
|
||
self.player.play()
|
||
# Ensure Qt's sink-input is routed to the virtual sink immediately.
|
||
# Qt6's PipeWire-native backend ignores PULSE_SINK; pactl move-sink-input
|
||
# works regardless of which backend is active.
|
||
QTimer.singleShot(400, _route_qt_audio_to_virtual_sink)
|
||
self.vinyl.start_spin()
|
||
self._emit_playing_state(True)
|
||
self.level_meter.update_playback(
|
||
self.player.position(), self.wave.duration, True
|
||
)
|
||
# Stream the file directly to listeners (reliable regardless of audio backend)
|
||
mw = self.window()
|
||
if hasattr(mw, 'streaming_worker') and self.current_file_path:
|
||
mw.streaming_worker.switch_file(
|
||
self.current_file_path,
|
||
self.player.position(),
|
||
frozenset(),
|
||
)
|
||
|
||
def pause(self):
|
||
self.player.pause()
|
||
self.vinyl.stop_spin()
|
||
self._emit_playing_state(False)
|
||
self.level_meter.update_playback(
|
||
self.player.position(), self.wave.duration, False
|
||
)
|
||
mw = self.window()
|
||
if hasattr(mw, 'streaming_worker'):
|
||
mw.streaming_worker.stop_file()
|
||
|
||
def stop(self):
|
||
self.player.stop()
|
||
self.vinyl.stop_spin()
|
||
self.vinyl.angle = 0
|
||
self.vinyl.update()
|
||
self.clear_loop()
|
||
self._emit_playing_state(False)
|
||
self.level_meter.update_playback(0, 1, False)
|
||
mw = self.window()
|
||
if hasattr(mw, 'streaming_worker'):
|
||
mw.streaming_worker.stop_file()
|
||
|
||
def on_position_changed(self, pos):
|
||
self.wave.set_position(pos)
|
||
is_playing = (
|
||
self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState
|
||
)
|
||
self.level_meter.update_playback(pos, self.wave.duration, is_playing)
|
||
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:.2f}x")
|
||
self.vinyl.set_speed(rate)
|
||
# Update displayed BPM based on detected BPM (or DEFAULT_BPM) scaled by pitch
|
||
base = self.detected_bpm if self.detected_bpm else DEFAULT_BPM
|
||
self.lbl_bpm.setText(str(int(round(base * rate))))
|
||
|
||
def set_xf_vol(self, volume):
|
||
self.xf_vol = volume
|
||
self.recalc_vol()
|
||
|
||
def recalc_vol(self):
|
||
# Volume is controlled only by the LEV fader and the crossfader.
|
||
# The HI/MID/LO sliders are visual EQ trims; they do not affect
|
||
# QMediaPlayer output volume (QMediaPlayer has no EQ API).
|
||
final = (self.xf_vol / 100.0) * (self.sl_vol.value() / MAX_SLIDER_VALUE)
|
||
self.audio_output.setVolume(final)
|
||
|
||
class DJApp(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("TechDJ Pro - Neon Edition")
|
||
self.resize(1280, 980)
|
||
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
|
||
|
||
# Periodically ensure Qt's audio sink-inputs are routed to the virtual
|
||
# sink. Qt6 may use the PipeWire-native audio backend which ignores
|
||
# PULSE_SINK, causing the streaming ffmpeg to capture silence instead
|
||
# of the DJ's music. pactl move-sink-input fixes this regardless of
|
||
# which Qt audio backend is active.
|
||
self._audio_route_timer = QTimer()
|
||
self._audio_route_timer.timeout.connect(_route_qt_audio_to_virtual_sink)
|
||
self._audio_route_timer.start(12000) # run every 12 s — reduces subprocess overhead
|
||
# Also fire once shortly after startup so Qt's first audio stream is routed
|
||
# before the 12-second interval elapses (relevant for PipeWire-native backend).
|
||
QTimer.singleShot(1500, _route_qt_audio_to_virtual_sink)
|
||
|
||
# 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")
|
||
self.setCentralWidget(main)
|
||
layout = QVBoxLayout(main)
|
||
layout.setContentsMargins(12, 10, 12, 10)
|
||
layout.setSpacing(6)
|
||
|
||
# Create glow overlay
|
||
self.glow_frame = GlowFrame(main)
|
||
self.glow_frame.setGeometry(main.rect())
|
||
self.glow_frame.raise_()
|
||
|
||
# ── Top bar: app title + YouTube search ──────────────────────────
|
||
h = QHBoxLayout()
|
||
h.setSpacing(6)
|
||
|
||
title_lbl = QLabel("[ TECHDJ PRO ]")
|
||
title_lbl.setStyleSheet(
|
||
"color:#00e5ff; font-family:'Courier New'; font-size:14px; "
|
||
"font-weight:bold; letter-spacing:3px;"
|
||
)
|
||
|
||
self.neon_button = QPushButton("NEON: OFF")
|
||
self.neon_button.setObjectName("btn_neon")
|
||
self.neon_button.setFixedWidth(110)
|
||
self.neon_button.setToolTip("Toggle neon border glow 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)
|
||
|
||
self.format_selector = QComboBox()
|
||
self.format_selector.addItem("MP3", "mp3")
|
||
self.format_selector.addItem("Best Quality", "best")
|
||
self.format_selector.setCurrentIndex(0)
|
||
self.format_selector.setToolTip("Download format: MP3 (converted) or Best (faster, original)")
|
||
self.format_selector.setFixedWidth(110)
|
||
|
||
self.search_button = QPushButton("GO")
|
||
self.search_button.setObjectName("btn_yt_go")
|
||
self.search_button.setFixedWidth(54)
|
||
self.search_button.setToolTip("Search / download from YouTube")
|
||
self.search_button.clicked.connect(self.search_youtube)
|
||
|
||
self.settings_btn = QPushButton("MAP")
|
||
self.settings_btn.setFixedWidth(60)
|
||
self.settings_btn.setToolTip("Keyboard shortcuts & settings")
|
||
self.settings_btn.clicked.connect(self.open_settings)
|
||
|
||
h.addWidget(title_lbl)
|
||
h.addSpacing(6)
|
||
h.addWidget(self.neon_button)
|
||
h.addSpacing(6)
|
||
h.addWidget(self.yt_input, 1)
|
||
h.addWidget(self.format_selector)
|
||
h.addWidget(self.search_button)
|
||
h.addWidget(self.settings_btn)
|
||
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("DOWNLOADING %p%")
|
||
self.download_progress_bar.setFixedHeight(14)
|
||
self.download_progress_bar.setVisible(False)
|
||
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 section
|
||
xf_outer = QWidget()
|
||
xf_outer.setStyleSheet(
|
||
"background: #04080f; border-top: 1px solid #0d1a26; border-bottom: 1px solid #0d1a26;"
|
||
)
|
||
xf_vbox = QVBoxLayout(xf_outer)
|
||
xf_vbox.setContentsMargins(40, 6, 40, 6)
|
||
xf_vbox.setSpacing(2)
|
||
|
||
xf_labels = QHBoxLayout()
|
||
xf_a = QLabel("<< DECK A")
|
||
xf_a.setStyleSheet("color:#00e5ff; font-family:'Courier New'; font-size:10px; letter-spacing:2px; font-weight:bold;")
|
||
xf_b = QLabel("DECK B >>")
|
||
xf_b.setStyleSheet("color:#ea00ff; font-family:'Courier New'; font-size:10px; letter-spacing:2px; font-weight:bold;")
|
||
xf_b.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||
xf_center_lbl = QLabel("-- CROSSFADER --")
|
||
xf_center_lbl.setStyleSheet(
|
||
"color:#556677; font-family:'Courier New'; font-size:9px; letter-spacing:3px;"
|
||
)
|
||
xf_center_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
xf_labels.addWidget(xf_a)
|
||
xf_labels.addWidget(xf_center_lbl, 1)
|
||
xf_labels.addWidget(xf_b)
|
||
xf_vbox.addLayout(xf_labels)
|
||
|
||
self.crossfader = QSlider(Qt.Orientation.Horizontal)
|
||
self.crossfader.setObjectName("crossfader")
|
||
self.crossfader.setRange(0, 100)
|
||
self.crossfader.setValue(50)
|
||
self.crossfader.setFixedHeight(46)
|
||
self.crossfader.setToolTip("Crossfade between decks (Left = Deck A, Right = Deck B)")
|
||
self.crossfader.valueChanged.connect(self.update_crossfade)
|
||
xf_vbox.addWidget(self.crossfader)
|
||
|
||
layout.addWidget(xf_outer)
|
||
|
||
# ── Recording / Streaming / Glow bar ─────────────────────────────
|
||
rec_layout = QHBoxLayout()
|
||
rec_layout.setContentsMargins(30, 6, 30, 6)
|
||
rec_layout.setSpacing(10)
|
||
|
||
# REC button
|
||
self.record_button = QPushButton("REC")
|
||
self.record_button.setFixedSize(90, 44)
|
||
self.record_button.setToolTip("Start/Stop recording your mix")
|
||
self.record_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a0000, stop:1 #0d0000);
|
||
color: #cc2222;
|
||
border: 2px solid #440000;
|
||
font-weight: bold;
|
||
font-size: 13px;
|
||
font-family: 'Courier New';
|
||
letter-spacing: 2px;
|
||
}
|
||
QPushButton:hover { background: #220000; border-color: #ff0000; color: #ff3333; }
|
||
""")
|
||
self.record_button.clicked.connect(self.toggle_recording)
|
||
|
||
self.recording_timer_label = QLabel("00:00")
|
||
self.recording_timer_label.setStyleSheet(
|
||
"color: #334455; font-size: 22px; font-weight: bold; font-family: 'Courier New';"
|
||
)
|
||
self.recording_timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.recording_timer_label.setFixedWidth(70)
|
||
|
||
self.recording_status_label = QLabel("STANDBY")
|
||
self.recording_status_label.setStyleSheet(
|
||
"color: #223344; font-size: 10px; font-family: 'Courier New'; letter-spacing: 1px;"
|
||
)
|
||
self.recording_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
rec_info = QVBoxLayout()
|
||
rec_info.setSpacing(1)
|
||
rec_info.addWidget(self.recording_timer_label)
|
||
rec_info.addWidget(self.recording_status_label)
|
||
|
||
# Separator
|
||
def _vline():
|
||
f = QFrame()
|
||
f.setFrameShape(QFrame.Shape.VLine)
|
||
f.setStyleSheet("color: #1a2233;")
|
||
return f
|
||
|
||
# LIVE button
|
||
self.stream_button = QPushButton("LIVE")
|
||
self.stream_button.setFixedSize(90, 44)
|
||
self.stream_button.setToolTip("Start/Stop live streaming")
|
||
self.stream_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #001528, stop:1 #000d18);
|
||
color: #1a5588;
|
||
border: 2px solid #0a2244;
|
||
font-weight: bold;
|
||
font-size: 13px;
|
||
font-family: 'Courier New';
|
||
letter-spacing: 2px;
|
||
}
|
||
QPushButton:hover { background: #001a33; border-color: #0066cc; color: #3399ff; }
|
||
""")
|
||
self.stream_button.clicked.connect(self.toggle_streaming)
|
||
|
||
self.stream_status_label = QLabel("OFFLINE")
|
||
self.stream_status_label.setStyleSheet(
|
||
"color: #223344; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px;"
|
||
)
|
||
self.stream_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
self.listener_count_label = QLabel("0 listeners")
|
||
self.listener_count_label.setStyleSheet(
|
||
"color: #1a3a55; font-size: 10px; font-weight: bold; font-family: 'Courier New';"
|
||
)
|
||
self.listener_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
stream_info = QVBoxLayout()
|
||
stream_info.setSpacing(1)
|
||
stream_info.addWidget(self.stream_status_label)
|
||
stream_info.addWidget(self.listener_count_label)
|
||
|
||
# Listener Glow
|
||
glow_title = QLabel("LISTENER GLOW")
|
||
glow_title.setStyleSheet(
|
||
"color: #7a22bb; font-size: 9px; font-weight: bold; "
|
||
"font-family: 'Courier New'; letter-spacing: 2px;"
|
||
)
|
||
glow_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
self.glow_slider = QSlider(Qt.Orientation.Horizontal)
|
||
self.glow_slider.setRange(0, 100)
|
||
self.glow_slider.setValue(30)
|
||
self.glow_slider.setFixedWidth(120)
|
||
self.glow_slider.setToolTip(
|
||
"Listener page glow intensity\n0 = off | 100 = max\nSends to listeners in real-time"
|
||
)
|
||
self.glow_slider.setStyleSheet("""
|
||
QSlider::groove:horizontal {
|
||
border: 1px solid #330055;
|
||
height: 6px;
|
||
background: qlineargradient(x1:0,y1:0,x2:1,y2:0, stop:0 #0d0018, stop:1 #aa00ff);
|
||
border-radius: 3px;
|
||
}
|
||
QSlider::handle:horizontal {
|
||
background: #bb22ff;
|
||
border: 2px solid #cc44ff;
|
||
width: 14px; height: 14px; margin: -5px 0; border-radius: 7px;
|
||
}
|
||
QSlider::handle:horizontal:hover { background: #dd66ff; border-color: #fff; }
|
||
""")
|
||
self.glow_slider.valueChanged.connect(self.on_glow_slider_changed)
|
||
|
||
self.glow_value_label = QLabel("30")
|
||
self.glow_value_label.setStyleSheet("color: #7a22bb; font-size: 10px; font-family: 'Courier New';")
|
||
self.glow_value_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
glow_vbox = QVBoxLayout()
|
||
glow_vbox.setSpacing(2)
|
||
glow_vbox.addWidget(glow_title)
|
||
glow_vbox.addWidget(self.glow_slider, 0, Qt.AlignmentFlag.AlignHCenter)
|
||
glow_vbox.addWidget(self.glow_value_label)
|
||
|
||
# Status label (search/download feedback)
|
||
self.status_label = QLabel("")
|
||
self.status_label.setStyleSheet(
|
||
"color:#00ff88; font-weight:bold; font-family:'Courier New'; font-size:11px;"
|
||
)
|
||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
rec_layout.addStretch()
|
||
rec_layout.addWidget(self.record_button)
|
||
rec_layout.addSpacing(4)
|
||
rec_layout.addLayout(rec_info)
|
||
rec_layout.addWidget(_vline())
|
||
rec_layout.addWidget(self.stream_button)
|
||
rec_layout.addSpacing(4)
|
||
rec_layout.addLayout(stream_info)
|
||
rec_layout.addWidget(_vline())
|
||
rec_layout.addLayout(glow_vbox)
|
||
rec_layout.addWidget(_vline())
|
||
rec_layout.addWidget(self.status_label)
|
||
rec_layout.addStretch()
|
||
|
||
layout.addLayout(rec_layout)
|
||
|
||
# Library section
|
||
library_group = QGroupBox("TRACK LIBRARY")
|
||
library_group.setStyleSheet(
|
||
"QGroupBox { border: 1px solid #0d1a26; border-radius:4px; margin-top:10px; }"
|
||
"QGroupBox::title { color:#445566; font-size:10px; letter-spacing:3px; subcontrol-origin:margin; left:10px; }"
|
||
)
|
||
lib_layout = QVBoxLayout(library_group)
|
||
lib_layout.setSpacing(4)
|
||
lib_layout.setContentsMargins(6, 10, 6, 6)
|
||
|
||
button_row = QHBoxLayout()
|
||
button_row.setSpacing(4)
|
||
|
||
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
|
||
try:
|
||
with open(self.settings_file, "w") as f:
|
||
json.dump(self.all_settings, f, indent=4)
|
||
except Exception as e:
|
||
QMessageBox.warning(self, "Settings Warning",
|
||
f"Settings applied but could not be saved to disk:\n{e}")
|
||
# Still apply in-memory settings even if file write failed
|
||
self.setup_keyboard_shortcuts()
|
||
self.apply_ui_settings()
|
||
return
|
||
|
||
# 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: "#334455", 1: "#00e5ff", 2: "#ea00ff"}
|
||
color = colors[self.neon_state]
|
||
labels = ["OFF", "CYAN", "PURPLE"]
|
||
|
||
self.neon_button.setText(f"NEON: {labels[self.neon_state]}")
|
||
self.neon_button.setStyleSheet(f"color: {color}; border: 1px solid {color}44;")
|
||
|
||
if self.neon_state == 0:
|
||
self.glow_frame.set_glow(False)
|
||
else:
|
||
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...")
|
||
|
||
# Use the listener port (no auth required) for library / track data.
|
||
# The DJ port requires a password session which the Qt client doesn't hold.
|
||
listener_url = self.get_listener_base_url()
|
||
self.server_url = self.get_server_base_url() # kept for socket/streaming
|
||
|
||
self.fetcher = ServerLibraryFetcher(f"{listener_url}/library.json")
|
||
self.fetcher.finished.connect(
|
||
lambda tracks, err, success: self.on_server_library_fetched(tracks, listener_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":
|
||
filename = os.path.basename(file_path)
|
||
# Check for duplicates in local_library
|
||
if any(Path(track['path']).name.lower() == filename.lower() for track in self.local_library):
|
||
QMessageBox.information(self, "Import Skipped", f"'{filename}' is already in your local library.")
|
||
return
|
||
|
||
# Copy to local music folder
|
||
dest = self.lib_path / filename
|
||
try:
|
||
shutil.copy2(file_path, dest)
|
||
self.status_label.setText(f"Imported: {filename}")
|
||
self.load_library()
|
||
except Exception as e:
|
||
QMessageBox.warning(self, "Import Error", f"Failed to import file: {e}")
|
||
else:
|
||
# Upload to server
|
||
filename = os.path.basename(file_path)
|
||
# Check for duplicates in server_library
|
||
if hasattr(self, 'server_library'):
|
||
if any(track['file'].split('/')[-1].lower() == filename.lower() for track in self.server_library):
|
||
QMessageBox.information(self, "Upload Skipped", f"'{filename}' already exists on the server.")
|
||
return
|
||
|
||
try:
|
||
self.status_label.setText("Uploading to server...")
|
||
base_url = self.get_server_base_url()
|
||
password = self.all_settings.get("audio", {}).get("dj_panel_password", "")
|
||
|
||
# Authenticate if a password is configured (same approach as StreamingWorker)
|
||
session_cookie = None
|
||
if password:
|
||
session_cookie = StreamingWorker._get_auth_cookie(base_url, password)
|
||
if not session_cookie:
|
||
QMessageBox.warning(self, "Upload Error", "DJ panel authentication failed.\nCheck the password in Settings → Audio.")
|
||
self.status_label.setText("Upload failed")
|
||
return
|
||
|
||
headers = {}
|
||
if session_cookie:
|
||
headers['Cookie'] = session_cookie
|
||
|
||
with open(file_path, 'rb') as f:
|
||
files = {'file': f}
|
||
response = requests.post(f"{base_url}/upload", files=files, headers=headers, 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:
|
||
from datetime import datetime
|
||
audio_settings = self.all_settings.get("audio", {})
|
||
fmt = audio_settings.get("recording_format", "wav")
|
||
sample_rate = audio_settings.get("recording_sample_rate", 48000)
|
||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||
filename = f"mix_{timestamp}.{fmt}"
|
||
output_path = str(self.recordings_path / filename)
|
||
|
||
if self.recording_worker.start_recording(output_path, fmt=fmt, sample_rate=sample_rate):
|
||
self.is_recording = True
|
||
self.recording_start_time = time.time()
|
||
self.recording_timer.start(1000)
|
||
|
||
self.record_button.setText("STOP REC")
|
||
self.record_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #330000, stop:1 #1a0000);
|
||
color: #ff2222;
|
||
border: 2px solid #ff0000;
|
||
font-weight: bold; font-size: 13px;
|
||
font-family: 'Courier New'; letter-spacing: 2px;
|
||
}
|
||
QPushButton:hover { background: #440000; border-color: #ff4444; }
|
||
""")
|
||
self.recording_status_label.setText("[REC]")
|
||
self.recording_status_label.setStyleSheet(
|
||
"color: #ff2222; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px;"
|
||
)
|
||
else:
|
||
self.recording_worker.stop_recording()
|
||
self.is_recording = False
|
||
self.recording_timer.stop()
|
||
|
||
self.record_button.setText("REC")
|
||
self.record_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a0000, stop:1 #0d0000);
|
||
color: #cc2222;
|
||
border: 2px solid #440000;
|
||
font-weight: bold; font-size: 13px;
|
||
font-family: 'Courier New'; letter-spacing: 2px;
|
||
}
|
||
QPushButton:hover { background: #220000; border-color: #ff0000; color: #ff3333; }
|
||
""")
|
||
self.recording_timer_label.setText("00:00")
|
||
self.recording_timer_label.setStyleSheet(
|
||
"color: #334455; font-size: 22px; font-weight: bold; font-family: 'Courier New';"
|
||
)
|
||
self.recording_status_label.setText("SAVED")
|
||
self.recording_status_label.setStyleSheet(
|
||
"color: #00ff88; font-size: 10px; font-family: 'Courier New'; letter-spacing: 1px;"
|
||
)
|
||
|
||
def _reset_rec_status():
|
||
self.recording_status_label.setText("STANDBY")
|
||
self.recording_status_label.setStyleSheet(
|
||
"color: #223344; font-size: 10px; font-family: 'Courier New'; letter-spacing: 1px;"
|
||
)
|
||
QTimer.singleShot(3000, _reset_rec_status)
|
||
|
||
def update_recording_time(self):
|
||
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: #ff2222; font-size: 22px; font-weight: bold; font-family: 'Courier New';"
|
||
)
|
||
|
||
def on_recording_error(self, error_msg):
|
||
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("ERROR")
|
||
self.recording_status_label.setStyleSheet(
|
||
"color: #ff0000; font-size: 10px; font-family: 'Courier New';"
|
||
)
|
||
|
||
def toggle_streaming(self):
|
||
if not self.is_streaming:
|
||
base_url = self.get_server_base_url()
|
||
bitrate = self.all_settings.get("audio", {}).get("bitrate", 128)
|
||
password = self.all_settings.get("audio", {}).get("dj_panel_password", "")
|
||
|
||
if self.streaming_worker.start_streaming(base_url, bitrate, password=password):
|
||
self.is_streaming = True
|
||
self.stream_button.setText("STOP")
|
||
self.stream_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #002244, stop:1 #001122);
|
||
color: #00ff88;
|
||
border: 2px solid #0066cc;
|
||
font-weight: bold; font-size: 13px;
|
||
font-family: 'Courier New'; letter-spacing: 2px;
|
||
}
|
||
QPushButton:hover { background: #003366; border-color: #00ff88; }
|
||
""")
|
||
self.stream_status_label.setText("LIVE")
|
||
self.stream_status_label.setStyleSheet(
|
||
"color: #ff2222; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px; font-weight: bold;"
|
||
)
|
||
else:
|
||
self.streaming_worker.stop_streaming()
|
||
self.is_streaming = False
|
||
self.stream_button.setText("LIVE")
|
||
self.stream_button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #001528, stop:1 #000d18);
|
||
color: #1a5588;
|
||
border: 2px solid #0a2244;
|
||
font-weight: bold; font-size: 13px;
|
||
font-family: 'Courier New'; letter-spacing: 2px;
|
||
}
|
||
QPushButton:hover { background: #001a33; border-color: #0066cc; color: #3399ff; }
|
||
""")
|
||
self.stream_status_label.setText("OFFLINE")
|
||
self.stream_status_label.setStyleSheet(
|
||
"color: #223344; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px;"
|
||
)
|
||
|
||
def on_streaming_error(self, error_msg):
|
||
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: #ff2222; font-size: 10px; font-family: 'Courier New';"
|
||
)
|
||
|
||
def update_listener_count(self, count):
|
||
self.listener_count_label.setText(f"{count} listener{'s' if count != 1 else ''}")
|
||
self.listener_count_label.setStyleSheet(
|
||
"color: #3399ff; font-size: 10px; font-weight: bold; font-family: 'Courier New';"
|
||
)
|
||
|
||
def on_glow_slider_changed(self, val):
|
||
"""Send listener glow intensity to all connected listeners in real-time."""
|
||
self.glow_value_label.setText(str(val))
|
||
# Keep the worker's glow_intensity in sync so it's sent on reconnect too
|
||
self.streaming_worker.glow_intensity = val
|
||
self.streaming_worker.emit_if_connected('listener_glow', {'intensity': val})
|
||
|
||
def get_listener_base_url(self):
|
||
"""Return the listener server base URL (no auth required).
|
||
|
||
The listener server runs on DJ_port + 1 by convention (default 5001).
|
||
Library JSON and music_proxy routes are available there without any
|
||
password, so we use this for all library / track-download requests.
|
||
"""
|
||
audio_settings = self.all_settings.get("audio", {})
|
||
raw = audio_settings.get("stream_server_url", "http://localhost:5000").strip()
|
||
|
||
if not raw.startswith(("http://", "https://", "ws://", "wss://")):
|
||
raw = "http://" + raw
|
||
|
||
parsed = urlparse(raw)
|
||
host = parsed.hostname or "localhost"
|
||
dj_port = parsed.port or 5000
|
||
|
||
# Normalise: if the configured port IS the listener port, keep it;
|
||
# otherwise derive listener port as DJ port + 1.
|
||
if dj_port == 5001:
|
||
listener_port = 5001
|
||
else:
|
||
# Remap any reverse-proxy or listener port back to DJ port first,
|
||
# then add 1 to get the listener port.
|
||
if dj_port == 8080:
|
||
dj_port = 5000
|
||
listener_port = dj_port + 1
|
||
|
||
return f"http://{host}:{listener_port}"
|
||
|
||
def get_server_base_url(self):
|
||
audio_settings = self.all_settings.get("audio", {})
|
||
raw = audio_settings.get("stream_server_url", "http://localhost:5000").strip()
|
||
|
||
# Ensure scheme is present so urlparse parses host/port correctly
|
||
if not raw.startswith(("http://", "https://", "ws://", "wss://")):
|
||
raw = "http://" + raw
|
||
|
||
parsed = urlparse(raw)
|
||
host = parsed.hostname or "localhost"
|
||
port = parsed.port or 5000
|
||
|
||
# Remap listener port and common reverse-proxy port to the DJ server port
|
||
if port in (5001, 8080):
|
||
port = 5000
|
||
|
||
# Always use plain HTTP — the server never terminates TLS directly.
|
||
# Any path component (e.g. /stream.mp3, /api/stream) is intentionally stripped.
|
||
return f"http://{host}:{port}"
|
||
|
||
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.streaming_worker.wait(3000) # allow thread to finish cleanly
|
||
|
||
self.deck_a.stop()
|
||
self.deck_b.stop()
|
||
self.search_worker.kill()
|
||
self.download_worker.kill()
|
||
event.accept()
|
||
|
||
if __name__ == "__main__":
|
||
# Create virtual sink BEFORE QApplication so Qt's audio output
|
||
# is automatically routed to it via PULSE_SINK env var.
|
||
_setup_audio_isolation()
|
||
|
||
app = QApplication(sys.argv)
|
||
app.setApplicationName("TechDJ Pro")
|
||
app.setDesktopFileName("techdj-pro")
|
||
window = DJApp()
|
||
window.show()
|
||
sys.exit(app.exec())
|