diff --git a/.gitignore b/.gitignore index 4ef34e7..48f7452 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ music/ +recordings/ # Python __pycache__/ diff --git a/compare_memory.py b/compare_memory.py index 3346192..8cfa6cf 100755 --- a/compare_memory.py +++ b/compare_memory.py @@ -41,23 +41,23 @@ def main(): # Check Chrome chrome_mem, chrome_procs = get_process_memory('chrome') if chrome_mem > 0: - print(f"๐ŸŒ Chrome (Web Panel):") + print(f"[CHROME] Chrome (Web Panel):") print(f" Total Memory: {chrome_mem:.1f} MB") print(f" Processes: {chrome_procs}") print() else: - print("๐ŸŒ Chrome: Not running") + print("[CHROME] Chrome: Not running") print() - # Check PyQt5 + # Check PyQt6 qt_mem, qt_procs = get_process_memory('techdj_qt') if qt_mem > 0: - print(f"๐Ÿ’ป PyQt5 Native App:") + print(f"[PYQT6] PyQt6 Native App:") print(f" Total Memory: {qt_mem:.1f} MB") print(f" Processes: {qt_procs}") print() else: - print("๐Ÿ’ป PyQt5 Native App: Not running") + print("[PYQT6] PyQt6 Native App: Not running") print() # Comparison @@ -66,25 +66,25 @@ def main(): percent = (savings / chrome_mem) * 100 print("=" * 60) - print("๐Ÿ“Š Comparison:") + print("[STATS] Comparison:") print(f" Memory Saved: {savings:.1f} MB ({percent:.1f}%)") print() # Visual bar chart max_mem = max(chrome_mem, qt_mem) - chrome_bar = 'โ–ˆ' * int((chrome_mem / max_mem) * 40) - qt_bar = 'โ–ˆ' * int((qt_mem / max_mem) * 40) + chrome_bar = '#' * int((chrome_mem / max_mem) * 40) + qt_bar = '#' * int((qt_mem / max_mem) * 40) print(" Chrome: " + chrome_bar + f" {chrome_mem:.0f}MB") - print(" PyQt5: " + qt_bar + f" {qt_mem:.0f}MB") + print(" PyQt6: " + qt_bar + f" {qt_mem:.0f}MB") print() if percent > 50: - print(f" โœ… PyQt5 uses {percent:.0f}% less memory!") + print(f" [OK] PyQt6 uses {percent:.0f}% less memory!") elif percent > 25: - print(f" โœ… PyQt5 uses {percent:.0f}% less memory") + print(f" [OK] PyQt6 uses {percent:.0f}% less memory") else: - print(f" PyQt5 uses {percent:.0f}% less memory") + print(f" PyQt6 uses {percent:.0f}% less memory") print("=" * 60) print() diff --git a/dj_icon.png b/dj_icon.png new file mode 100644 index 0000000..b856916 Binary files /dev/null and b/dj_icon.png differ diff --git a/index.html b/index.html index 0b0eebf..7a8f15d 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,15 @@ USE PORTRAIT MODE + +
+
+ + NEON_v2 +
+ +
00:00
+
@@ -36,31 +45,33 @@
-
- - -
+
+
@@ -194,7 +205,7 @@ -
@@ -314,14 +325,13 @@ -
-
@@ -330,7 +340,8 @@

QUEUE A

- +
Drop tracks here or click "Queue to A" in library
@@ -340,7 +351,8 @@

QUEUE B

- +
Drop tracks here or click "Queue to B" in library
@@ -441,6 +453,7 @@ +
@@ -477,14 +490,27 @@
- - - - +
+ +
+ + + + +
+
+ + + + + - - + + \ No newline at end of file diff --git a/launch_qt.sh b/launch_qt.sh index 7919edd..214b1db 100755 --- a/launch_qt.sh +++ b/launch_qt.sh @@ -1,31 +1,31 @@ #!/bin/bash -# TechDJ PyQt5 Launcher Script +# TechDJ PyQt6 Launcher Script -echo "๐ŸŽง TechDJ PyQt5 - Native DJ Application" +echo "TechDJ PyQt6 - Native DJ Application" echo "========================================" echo "" # Activate virtual environment if it exists if [ -f "./.venv/bin/activate" ]; then - echo "๐Ÿ”ง Activating virtual environment (.venv)..." + echo "Activating virtual environment (.venv)..." source ./.venv/bin/activate elif [ -f "./venv/bin/activate" ]; then - echo "๐Ÿ”ง Activating virtual environment (venv)..." + echo "Activating virtual environment (venv)..." source ./venv/bin/activate fi # Check if Python is installed if ! command -v python3 &> /dev/null; then - echo "โŒ Python 3 is not installed!" + echo "[ERROR] Python 3 is not installed!" echo "Please install Python 3 first." exit 1 fi -echo "โœ… Python 3 found: $(python3 --version)" +echo "[OK] Python 3 found: $(python3 --version)" # Check if pip is installed if ! command -v pip3 &> /dev/null; then - echo "โš ๏ธ pip3 not found. Installing..." + echo "[WARN] pip3 not found. Installing..." echo "Please run: sudo apt install python3-pip" exit 1 fi @@ -36,55 +36,63 @@ echo "Checking dependencies..." MISSING_DEPS=0 -# Check PyQt5 -if ! python3 -c "import PyQt5" 2>/dev/null; then - echo "โŒ PyQt5 not installed" +# Check PyQt6 +if ! python3 -c "import PyQt6" 2>/dev/null; then + echo "[ERROR] PyQt6 not installed" MISSING_DEPS=1 else - echo "โœ… PyQt5 installed" + echo "[OK] PyQt6 installed" fi # Check sounddevice if ! python3 -c "import sounddevice" 2>/dev/null; then - echo "โŒ sounddevice not installed" + echo "[ERROR] sounddevice not installed" MISSING_DEPS=1 else - echo "โœ… sounddevice installed" + echo "[OK] sounddevice installed" fi # Check soundfile if ! python3 -c "import soundfile" 2>/dev/null; then - echo "โŒ soundfile not installed" + echo "[ERROR] soundfile not installed" MISSING_DEPS=1 else - echo "โœ… soundfile installed" + echo "[OK] soundfile installed" fi # Check numpy if ! python3 -c "import numpy" 2>/dev/null; then - echo "โŒ numpy not installed" + echo "[ERROR] numpy not installed" MISSING_DEPS=1 else - echo "โœ… numpy installed" + echo "[OK] numpy installed" fi # Check socketio if ! python3 -c "import socketio" 2>/dev/null; then - echo "โŒ python-socketio not installed" + echo "[ERROR] python-socketio not installed" MISSING_DEPS=1 else - echo "โœ… python-socketio installed" + echo "[OK] python-socketio installed" +fi + +# Check yt-dlp +if ! python3 -c "import yt_dlp" 2>/dev/null; then + echo "[ERROR] yt-dlp not installed" + MISSING_DEPS=1 +else + echo "[OK] yt-dlp installed" fi # Install missing dependencies if [ $MISSING_DEPS -eq 1 ]; then if [[ "$*" == *"--noint"* ]]; then - echo "โš ๏ธ Missing dependencies detected in non-interactive mode" + echo "[WARN] Missing dependencies detected in non-interactive mode" echo "Please run './launch_qt.sh' from terminal to install dependencies" # Continue anyway - dependencies might be installed elsewhere else echo "" - echo "๐Ÿ“ฆ Installing missing dependencies..." + echo "[INSTALL] Installing missing dependencies..." echo "This may take a few minutes..." echo "" @@ -92,27 +100,27 @@ if [ $MISSING_DEPS -eq 1 ]; then echo "Installing system dependencies..." if command -v apt-get &> /dev/null; then echo "Detected Debian/Ubuntu system" - echo "You may need to run: sudo apt-get install portaudio19-dev python3-pyqt5" + echo "You may need to run: sudo apt-get install portaudio19-dev python3-pyqt6 python3-pyqt6.qtmultimedia libqt6multimedia6-plugins" elif command -v dnf &> /dev/null; then echo "Detected Fedora system" - echo "You may need to run: sudo dnf install portaudio-devel python3-qt5" + echo "You may need to run: sudo dnf install portaudio-devel python3-qt6" elif command -v pacman &> /dev/null; then echo "Detected Arch system" - echo "You may need to run: sudo pacman -S portaudio python-pyqt5" + echo "You may need to run: sudo pacman -S portaudio python-pyqt6" fi echo "" echo "Installing Python packages..." - pip3 install --user PyQt5 sounddevice soundfile numpy python-socketio[client] requests + pip3 install --user PyQt6 sounddevice soundfile numpy python-socketio[client] requests yt-dlp if [ $? -ne 0 ]; then - echo "โŒ Installation failed!" + echo "[ERROR] Installation failed!" echo "Please install dependencies manually:" echo " pip3 install --user -r requirements.txt" exit 1 fi - echo "โœ… Dependencies installed successfully!" + echo "[OK] Dependencies installed successfully!" fi fi @@ -140,9 +148,9 @@ fi echo "Checking server at: $SERVER_URL" if curl -s --max-time 2 "${SERVER_URL}/library.json" > /dev/null 2>&1; then - echo "โœ… Flask server is running at $SERVER_URL" + echo "[OK] Flask server is running at $SERVER_URL" else - echo "โš ๏ธ Flask server not detected at $SERVER_URL" + echo "[WARN] Flask server not detected at $SERVER_URL" if [[ "$*" == *"--noint"* ]]; then echo "Proceeding in non-interactive mode..." else @@ -158,10 +166,10 @@ fi # Launch the application echo "" -echo "๐Ÿš€ Launching TechDJ PyQt5..." +echo "Launching TechDJ PyQt6..." echo "" python3 techdj_qt.py echo "" -echo "๐Ÿ‘‹ TechDJ PyQt5 closed" +echo "TechDJ PyQt6 closed" diff --git a/requirements.txt b/requirements.txt index 13cad7b..89a6f3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,10 +4,11 @@ flask-socketio eventlet python-dotenv -# PyQt5 Native App Dependencies -PyQt5 +# PyQt6 Native App Dependencies +PyQt6 sounddevice soundfile numpy requests python-socketio[client] +yt-dlp diff --git a/script.js b/script.js index 54788a8..229afc6 100644 --- a/script.js +++ b/script.js @@ -403,6 +403,8 @@ function toggleDeck(id) { } // Mobile Tab Switching +let currentQueueTab = 'A'; // Track which queue is shown + function switchTab(tabId) { const container = document.querySelector('.app-container'); const buttons = document.querySelectorAll('.tab-btn'); @@ -413,9 +415,6 @@ function switchTab(tabId) { buttons.forEach(btn => btn.classList.remove('active')); sections.forEach(sec => sec.classList.remove('active')); - // Normalize IDs (deck-A -> deckA for class) - const normalizedId = tabId.replace('-', ''); - // Add active class and button state container.classList.add('show-' + tabId); @@ -426,11 +425,18 @@ function switchTab(tabId) { // Find the button and activate it buttons.forEach(btn => { const onClickAttr = btn.getAttribute('onclick'); - if (onClickAttr && onClickAttr.includes(tabId)) { + if (onClickAttr && (onClickAttr.includes(tabId) || (tabId.startsWith('queue') && onClickAttr.includes('switchQueueTab')))) { btn.classList.add('active'); } }); + // Update queue tab label to reflect which queue is showing + if (tabId.startsWith('queue')) { + currentQueueTab = tabId.includes('A') ? 'A' : 'B'; + const label = document.getElementById('queue-tab-label'); + if (label) label.textContent = 'QUEUE ' + currentQueueTab; + } + // Redraw waveforms if switching to a deck if (tabId.startsWith('deck')) { const id = tabId.includes('-') ? tabId.split('-')[1] : (tabId.includes('A') ? 'A' : 'B'); @@ -441,20 +447,23 @@ function switchTab(tabId) { vibrate(10); } -let libraryMode = 'server'; // 'server' or 'local' +// Queue tab cycles between Queue A and Queue B +function switchQueueTab() { + const container = document.querySelector('.app-container'); + const isQueueActive = container.classList.contains('show-queue-A') || container.classList.contains('show-queue-B'); -function setLibraryMode(mode) { - libraryMode = mode; - - // Update UI buttons - document.getElementById('btn-server-lib').classList.toggle('active', mode === 'server'); - document.getElementById('btn-local-lib').classList.toggle('active', mode === 'local'); - - // Refresh list - renderLibrary(); - vibrate(15); + if (!isQueueActive) { + // First tap: show current queue + switchTab('queue-' + currentQueueTab); + } else { + // Already on a queue tab: toggle between A and B + const nextQueue = currentQueueTab === 'A' ? 'B' : 'A'; + switchTab('queue-' + nextQueue); + } } + + function toggleMobileLibrary() { // This is now handled by tabs, but keep for compatibility if needed const lib = document.querySelector('.library-section'); @@ -462,12 +471,6 @@ function toggleMobileLibrary() { vibrate(20); } -function dismissLandscapePrompt() { - const prompt = document.getElementById('landscape-prompt'); - if (prompt) prompt.classList.add('dismissed'); - vibrate(10); -} - // Mobile Haptic Helper function vibrate(ms) { if (navigator.vibrate) { @@ -475,6 +478,36 @@ function vibrate(ms) { } } +// Mobile FAB Menu Toggle +function toggleFabMenu(e) { + if (e) e.stopPropagation(); + const menu = document.getElementById('fab-menu'); + const fab = document.querySelector('.fab-main'); + if (menu) menu.classList.toggle('active'); + if (fab) fab.classList.toggle('active'); + vibrate(10); +} + +// Close menu when clicking outside +document.addEventListener('click', () => { + const menu = document.getElementById('fab-menu'); + const fab = document.querySelector('.fab-main'); + if (menu && menu.classList.contains('active')) { + menu.classList.remove('active'); + fab.classList.remove('active'); + } +}); + +// Update Clock +setInterval(() => { + const clock = document.getElementById('clock-display'); + if (clock) { + const now = new Date(); + clock.textContent = now.getHours().toString().padStart(2, '0') + ':' + + now.getMinutes().toString().padStart(2, '0'); + } +}, 1000); + // Fullscreen Toggle function toggleFullScreen() { if (!document.fullscreenElement) { @@ -512,12 +545,18 @@ function handleSwipe() { const activeBtn = document.querySelector('.tab-btn.active'); if (!activeBtn) return; - const tabs = ['library', 'deck-A', 'deck-B']; + const tabs = ['library', 'deck-A', 'deck-B', 'queue-A', 'queue-B']; let currentIndex = -1; - if (activeBtn.getAttribute('onclick').includes('library')) currentIndex = 0; - else if (activeBtn.getAttribute('onclick').includes('deck-A')) currentIndex = 1; - else if (activeBtn.getAttribute('onclick').includes('deck-B')) currentIndex = 2; + const onClickAttr = activeBtn.getAttribute('onclick') || ''; + if (onClickAttr.includes('library')) currentIndex = 0; + else if (onClickAttr.includes('deck-A')) currentIndex = 1; + else if (onClickAttr.includes('deck-B')) currentIndex = 2; + else if (onClickAttr.includes('switchQueueTab')) { + // Determine which queue is active + const container = document.querySelector('.app-container'); + currentIndex = container.classList.contains('show-queue-B') ? 4 : 3; + } if (currentIndex === -1) return; @@ -612,6 +651,27 @@ function drawWaveform(id) { } ctx.setLineDash([]); } + + // Apply manual glow if enabled + if (settings[`glow${id}`] && !decks[id].playing) { + applyGlow(id, settings.glowIntensity); + } else { + removeGlow(id); + } +} + +function applyGlow(id, intensity) { + const deckEl = document.getElementById('deck-' + id); + if (!deckEl) return; + const color = id === 'A' ? 'var(--primary-cyan)' : 'var(--secondary-magenta)'; + const blur = Math.max(10, intensity); + const spread = Math.max(2, intensity / 5); + deckEl.style.boxShadow = `0 0 ${blur}px ${spread}px ${color}`; +} + +function removeGlow(id) { + const deckEl = document.getElementById('deck-' + id); + if (deckEl) deckEl.style.boxShadow = ''; } // BPM Detection (Optimised: Only check middle 60 seconds for speed) @@ -714,11 +774,13 @@ function playDeck(id) { vibrate(15); // Server-side audio mode if (SERVER_SIDE_AUDIO) { - if (!socket) initSocket(); socket.emit('audio_play', { deck: id }); decks[id].playing = true; const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.add('playing'); + document.body.classList.add('playing-' + id); + + console.log(`[Deck ${id}] Play command sent to server`); return; } @@ -734,6 +796,9 @@ function playDeck(id) { const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.add('playing'); + document.body.classList.add('playing-' + id); + + if (audioCtx.state === 'suspended') { console.log(`[Deck ${id}] Resuming suspended AudioContext`); @@ -752,6 +817,9 @@ function playDeck(id) { decks[id].playing = false; const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.remove('playing'); + document.body.classList.remove('playing-' + id); + + alert(`Playback error: ${error.message}`); } } else { @@ -766,7 +834,10 @@ function pauseDeck(id) { if (!socket) initSocket(); socket.emit('audio_pause', { deck: id }); decks[id].playing = false; - document.getElementById('deck-' + id).classList.remove('playing'); + const deckEl = document.getElementById('deck-' + id); + if (deckEl) deckEl.classList.remove('playing'); + document.body.classList.remove('playing-' + id); + console.log(`[Deck ${id}] Pause command sent to server`); return; } @@ -790,9 +861,12 @@ function pauseDeck(id) { decks[id].playing = false; } } - document.getElementById('deck-' + id).classList.remove('playing'); + const deckEl = document.getElementById('deck-' + id); + if (deckEl) deckEl.classList.remove('playing'); + document.body.classList.remove('playing-' + id); } + function seekTo(id, time) { // Update local state and timestamp for seek protection decks[id].lastSeekTime = Date.now(); @@ -1219,17 +1293,10 @@ function renderLibrary(songs) { const list = document.getElementById('library-list'); list.innerHTML = ''; - // Filter by mode (Mobile Drawer specifically relies on this) - const filteredSongs = songs.filter(s => { - // If it's a server track, its type is usually 'remote' or it has a URL - // If it's a local track, its type is 'local' - // For simplicity, let's look for a type field - if (libraryMode === 'local') return s.type === 'local'; - return s.type !== 'local'; - }); + const filteredSongs = songs; if (filteredSongs.length === 0) { - list.innerHTML = `
No ${libraryMode} tracks found.
`; + list.innerHTML = `
No tracks found.
`; return; } @@ -1341,7 +1408,7 @@ function refreshLibrary() { async function loadFromServer(id, url, title) { const d = document.getElementById('display-' + id); - d.innerText = 'โณ LOADING...'; + d.innerText = '[WAIT] LOADING...'; d.classList.add('blink'); console.log(`[Deck ${id}] Loading: ${title} from ${url}`); @@ -1366,7 +1433,7 @@ async function loadFromServer(id, url, title) { pauseDeck(id); console.log(`[Deck ${id}] Paused for song load`); } else if (wasPlaying && wasBroadcasting) { - console.log(`[Deck ${id}] โšก BROADCAST MODE: Keeping deck playing during load to maintain stream`); + console.log(`[Deck ${id}] [LIVE] BROADCAST MODE: Keeping deck playing during load to maintain stream`); } decks[id].waveformData = null; @@ -1471,50 +1538,204 @@ function toggleSettings() { } // File Upload +// File Upload with Progress and Parallelism async function handleFileUpload(event) { - const files = event.target.files; + const files = Array.from(event.target.files); if (!files || files.length === 0) return; console.log(`Uploading ${files.length} file(s)...`); - for (let file of files) { - if (!file.type.match('audio/mpeg') && !file.name.endsWith('.mp3')) { - alert(`${file.name} is not an MP3 file`); - continue; + // Create/Show progress container + let progressContainer = document.getElementById('upload-progress-container'); + if (!progressContainer) { + progressContainer = document.createElement('div'); + progressContainer.id = 'upload-progress-container'; + progressContainer.className = 'upload-progress-container'; + document.body.appendChild(progressContainer); + } + progressContainer.innerHTML = '

UPLOADING TRACKS...

'; + progressContainer.classList.add('active'); + + const uploadPromises = files.map(async (file) => { + const allowedExts = ['.mp3', '.m4a', '.wav', '.flac', '.ogg']; + const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); + if (!allowedExts.includes(ext)) { + console.warn(`${file.name} is not a supported audio file`); + return; } const formData = new FormData(); formData.append('file', file); + const progressRow = document.createElement('div'); + progressRow.className = 'upload-progress-row'; + const nameSpan = document.createElement('span'); + nameSpan.textContent = file.name.substring(0, 20) + (file.name.length > 20 ? '...' : ''); + const barWrap = document.createElement('div'); + barWrap.className = 'progress-bar-wrap'; + const barInner = document.createElement('div'); + barInner.className = 'progress-bar-inner'; + barInner.style.width = '0%'; + barWrap.appendChild(barInner); + progressRow.appendChild(nameSpan); + progressRow.appendChild(barWrap); + progressContainer.appendChild(progressRow); + try { - const response = await fetch('/upload', { - method: 'POST', - body: formData + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/upload', true); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const percent = (e.loaded / e.total) * 100; + barInner.style.width = percent + '%'; + } + }; + + xhr.onload = () => { + if (xhr.status === 200) { + const result = JSON.parse(xhr.responseText); + if (result.success) { + barInner.style.background = '#00ff88'; + resolve(); + } else { + barInner.style.background = '#ff4444'; + reject(new Error(result.error)); + } + } else { + barInner.style.background = '#ff4444'; + reject(new Error(`HTTP ${xhr.status}`)); + } + }; + + xhr.onerror = () => { + barInner.style.background = '#ff4444'; + reject(new Error('Network error')); + }; + + xhr.send(formData); }); - - const result = await response.json(); - - if (result.success) { - console.log(`โœ… Uploaded: ${file.name}`); - } else { - console.error(`โŒ Upload failed: ${result.error}`); - alert(`Failed to upload ${file.name}: ${result.error}`); - } } catch (error) { - console.error(`โŒ Upload error: ${error}`); - alert(`Error uploading ${file.name}`); + console.error(`[ERROR] Upload error: ${error}`); } + }); + + // Run uploads in parallel (limited to 3 at a time for stability if needed, but let's try all) + try { + await Promise.all(uploadPromises); + console.log('All uploads finished.'); + } catch (e) { + console.error('Some uploads failed', e); } // Refresh library - console.log('Refreshing library...'); - await loadLibrary(); - alert(`โœ… ${files.length} file(s) uploaded successfully!`); + setTimeout(() => { + fetchLibrary(); + setTimeout(() => { + progressContainer.classList.remove('active'); + vibrate(30); + }, 2000); + }, 500); - // Clear the input so the same file can be uploaded again if needed + // Clear the input event.target.value = ''; } +// Folder Selection Logic +async function openFolderPicker() { + let picker = document.getElementById('folder-picker-modal'); + if (!picker) { + picker = document.createElement('div'); + picker.id = 'folder-picker-modal'; + picker.className = 'modal-overlay'; + picker.innerHTML = ` + + `; + document.body.appendChild(picker); + } + picker.classList.add('active'); + + // Initial browse to home or current + browseToPath(''); +} + +function closeFolderPicker() { + document.getElementById('folder-picker-modal').classList.remove('active'); +} + +async function browseToPath(targetPath) { + const currentInput = document.getElementById('current-folder-path'); + let path = currentInput.value; + + if (targetPath === '..') { + // Handled by server if we send '..' but let's be explicit + const parts = path.split('/'); + parts.pop(); + path = parts.join('/') || '/'; + } else if (targetPath) { + path = targetPath; + } + + try { + const res = await fetch(`/browse_directories?path=${encodeURIComponent(path)}`); + const data = await res.json(); + if (data.success) { + currentInput.value = data.path; + const list = document.getElementById('dir-list'); + list.innerHTML = ''; + + data.entries.forEach(entry => { + const div = document.createElement('div'); + div.className = 'dir-entry'; + div.innerHTML = `[DIR] ${entry.name}`; + div.onclick = () => browseToPath(entry.path); + list.appendChild(div); + }); + } + } catch (e) { + console.error("Browse failed", e); + } +} + +async function confirmFolderSelection() { + const path = document.getElementById('current-folder-path').value; + try { + const res = await fetch('/update_settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + library: { music_folder: path } + }) + }); + const result = await res.json(); + if (result.success) { + alert("Music folder updated! Refreshing library..."); + closeFolderPicker(); + fetchLibrary(); + } else { + alert("Error: " + result.error); + } + } catch (e) { + alert("Failed to update settings"); + } +} + function toggleRepeat(id, val) { if (val === undefined) { settings[`repeat${id}`] = !settings[`repeat${id}`]; @@ -1703,7 +1924,7 @@ function initSocket() { const serverUrl = (isListenerMode && window.location.port === '5000') ? `${window.location.protocol}//${window.location.hostname}:5001` : window.location.origin; - console.log(`๐Ÿ”Œ Initializing Socket.IO connection to: ${serverUrl}`); + console.log(`CONNECT Initializing Socket.IO connection to: ${serverUrl}`); console.log(` Protocol: ${window.location.protocol}`); console.log(` Host: ${window.location.host}`); @@ -1714,7 +1935,7 @@ function initSocket() { }); socket.on('connect', () => { - console.log('โœ… Connected to streaming server'); + console.log('[OK] Connected to streaming server'); console.log(` Socket ID: ${socket.id}`); console.log(` Transport: ${socket.io.engine.transport.name}`); @@ -1723,12 +1944,12 @@ function initSocket() { }); socket.on('connect_error', (error) => { - console.error('โŒ Connection error:', error.message); + console.error('[ERROR] Connection error:', error.message); console.error(' Make sure server is running on', serverUrl); }); socket.on('disconnect', (reason) => { - console.log('โŒ Disconnected from streaming server'); + console.log('[ERROR] Disconnected from streaming server'); console.log(` Reason: ${reason}`); }); @@ -1748,11 +1969,14 @@ function initSocket() { }); socket.on('broadcast_stopped', () => { - console.log('๐Ÿ›‘ Broadcast stopped notification received'); + console.log('STOP Broadcast stopped notification received'); // Reset relay UI if it was active - document.getElementById('start-relay-btn').style.display = 'inline-block'; - document.getElementById('stop-relay-btn').style.display = 'none'; - document.getElementById('relay-status').textContent = ''; + const startRelayBtn = document.getElementById('start-relay-btn'); + const stopRelayBtn = document.getElementById('stop-relay-btn'); + const relayStatus = document.getElementById('relay-status'); + if (startRelayBtn) startRelayBtn.style.display = 'inline-block'; + if (stopRelayBtn) stopRelayBtn.style.display = 'none'; + if (relayStatus) relayStatus.textContent = ''; }); socket.on('mixer_status', (data) => { @@ -1768,9 +1992,12 @@ function initSocket() { console.error('Server error:', data.message); alert(`SERVER ERROR: ${data.message}`); // Reset relay UI on error - document.getElementById('start-relay-btn').style.display = 'inline-block'; - document.getElementById('stop-relay-btn').style.display = 'none'; - document.getElementById('relay-status').textContent = ''; + const startRelayBtn = document.getElementById('start-relay-btn'); + const stopRelayBtn = document.getElementById('stop-relay-btn'); + const relayStatus = document.getElementById('relay-status'); + if (startRelayBtn) startRelayBtn.style.display = 'inline-block'; + if (stopRelayBtn) stopRelayBtn.style.display = 'none'; + if (relayStatus) relayStatus.textContent = ''; }); return socket; @@ -1862,7 +2089,6 @@ function toggleBroadcast() { } } -// Start broadcasting // Start broadcasting function startBroadcast() { try { @@ -1878,7 +2104,7 @@ function startBroadcast() { isBroadcasting = true; document.getElementById('broadcast-btn').classList.add('active'); document.getElementById('broadcast-text').textContent = 'STOP BROADCAST'; - document.getElementById('broadcast-status').textContent = '๐Ÿ”ด LIVE'; + document.getElementById('broadcast-status').textContent = '[OFFLINE] LIVE'; document.getElementById('broadcast-status').classList.add('live'); if (!socket) initSocket(); @@ -1886,7 +2112,7 @@ function startBroadcast() { socket.emit('start_broadcast', { bitrate: bitrateValue }); socket.emit('get_listener_count'); - console.log('โœ… Server-side broadcast started'); + console.log('[OK] Server-side broadcast started'); return; } @@ -1911,7 +2137,7 @@ function startBroadcast() { } decks.A.crossfaderGain.connect(streamDestination); decks.A.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring - console.log('โœ… Deck A connected to stream + speakers'); + console.log('[OK] Deck A connected to stream + speakers'); } if (decks.B.crossfaderGain) { try { @@ -1921,12 +2147,12 @@ function startBroadcast() { } decks.B.crossfaderGain.connect(streamDestination); decks.B.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring - console.log('โœ… Deck B connected to stream + speakers'); + console.log('[OK] Deck B connected to stream + speakers'); } // Verify stream has audio tracks const stream = streamDestination.stream; - console.log(`๐Ÿ“Š Stream tracks: ${stream.getAudioTracks().length} audio tracks`); + console.log(`[DATA] Stream tracks: ${stream.getAudioTracks().length} audio tracks`); if (stream.getAudioTracks().length === 0) { throw new Error('No audio tracks in stream! Audio routing failed.'); @@ -2010,7 +2236,7 @@ function startBroadcast() { }; mediaRecorder.onerror = (error) => { - console.error('โŒ MediaRecorder error:', error); + console.error('[ERROR] MediaRecorder error:', error); // Try to recover from error if (isBroadcasting) { console.log('Attempting to recover from MediaRecorder error...'); @@ -2023,7 +2249,7 @@ function startBroadcast() { }; mediaRecorder.onstart = () => { - console.log('โœ… MediaRecorder started'); + console.log('[OK] MediaRecorder started'); }; mediaRecorder.onstop = (event) => { @@ -2033,7 +2259,7 @@ function startBroadcast() { // If we're supposed to be broadcasting but MediaRecorder stopped, restart it if (isBroadcasting) { - console.error('โŒ MediaRecorder stopped unexpectedly while broadcasting!'); + console.error('[ERROR] MediaRecorder stopped unexpectedly while broadcasting!'); console.log('Auto-recovery: Attempting to restart broadcast in 2 seconds...'); setTimeout(() => { @@ -2053,9 +2279,9 @@ function startBroadcast() { console.log('Auto-resuming MediaRecorder...'); try { mediaRecorder.resume(); - console.log('โœ… MediaRecorder resumed'); + console.log('[OK] MediaRecorder resumed'); } catch (e) { - console.error('โŒ Failed to resume MediaRecorder:', e); + console.error('[ERROR] Failed to resume MediaRecorder:', e); // If resume fails, try full restart setTimeout(() => { if (isBroadcasting) { @@ -2071,9 +2297,9 @@ function startBroadcast() { if (mediaRecorder.state === 'inactive') { mediaRecorder.start(1000); streamProcessor = mediaRecorder; - console.log('โœ… MediaRecorder started in state:', mediaRecorder.state); + console.log('[OK] MediaRecorder started in state:', mediaRecorder.state); } else { - console.error('โŒ Cannot start MediaRecorder - already in state:', mediaRecorder.state); + console.error('[ERROR] Cannot start MediaRecorder - already in state:', mediaRecorder.state); throw new Error(`MediaRecorder is already ${mediaRecorder.state}`); } @@ -2081,7 +2307,7 @@ function startBroadcast() { isBroadcasting = true; document.getElementById('broadcast-btn').classList.add('active'); document.getElementById('broadcast-text').textContent = 'STOP BROADCAST'; - document.getElementById('broadcast-status').textContent = '๐Ÿ”ด LIVE'; + document.getElementById('broadcast-status').textContent = '[OFFLINE] LIVE'; document.getElementById('broadcast-status').classList.add('live'); // Notify server that broadcast is active (listeners use MP3 stream) @@ -2090,13 +2316,13 @@ function startBroadcast() { socket.emit('start_broadcast', { bitrate: bitrateValue }); socket.emit('get_listener_count'); - console.log('โœ… Broadcasting started successfully!'); - console.log('๐Ÿ’ก TIP: Play a track on Deck A or B to stream audio'); + console.log('[OK] Broadcasting started successfully!'); + console.log('TIP TIP: Play a track on Deck A or B to stream audio'); // Monitor audio levels setTimeout(() => { if (chunkCount === 0) { - console.error('โŒ NO AUDIO CHUNKS after 2 seconds! Check:'); + console.error('[ERROR] NO AUDIO CHUNKS after 2 seconds! Check:'); console.error(' 1. Is audio playing on either deck?'); console.error(' 2. Is volume turned up?'); console.error(' 3. Is crossfader in the middle?'); @@ -2104,7 +2330,7 @@ function startBroadcast() { }, 2000); } catch (error) { - console.error('โŒ Failed to start broadcast:', error); + console.error('[ERROR] Failed to start broadcast:', error); alert('Failed to start broadcast: ' + error.message); isBroadcasting = false; } @@ -2112,7 +2338,7 @@ function startBroadcast() { // Stop broadcasting function stopBroadcast() { - console.log('๐Ÿ›‘ Stopping broadcast...'); + console.log('STOP Stopping broadcast...'); if (SERVER_SIDE_AUDIO) { isBroadcasting = false; @@ -2163,7 +2389,7 @@ function stopBroadcast() { document.getElementById('broadcast-status').textContent = 'Offline'; document.getElementById('broadcast-status').classList.remove('live'); - console.log('โœ… Broadcast stopped'); + console.log('[OK] Broadcast stopped'); } @@ -2212,7 +2438,7 @@ function restartBroadcast() { setTimeout(() => { if (wasBroadcasting) { startBroadcast(); - console.log('โœ… Broadcast restarted successfully'); + console.log('[OK] Broadcast restarted successfully'); } }, 100); } @@ -2227,7 +2453,7 @@ function copyStreamUrl(evt) { document.execCommand('copy'); const btn = evt?.target; const originalText = btn.textContent; - btn.textContent = 'โœ“'; + btn.textContent = 'OK'; setTimeout(() => { btn.textContent = originalText; }, 2000); @@ -2245,7 +2471,7 @@ function toggleAutoStream(enabled) { // ========== LISTENER MODE ========== function initListenerMode() { - console.log('๐ŸŽง Initializing listener mode (MP3 stream)...'); + console.log('STREAMPANEL Initializing listener mode (MP3 stream)...'); // UI Feedback for listener const appContainer = document.querySelector('.app-container'); @@ -2278,7 +2504,7 @@ function initListenerMode() { // Clean up old audio element if it exists if (window.listenerAudio) { - console.log('๐Ÿงน Cleaning up old audio element and AudioContext nodes'); + console.log('CLEAN Cleaning up old audio element and AudioContext nodes'); try { window.listenerAudio.pause(); if (window.listenerAudio.src) { @@ -2324,13 +2550,98 @@ function initListenerMode() { audio.playsInline = true; audio.setAttribute('playsinline', ''); audio.style.display = 'none'; + audio.crossOrigin = 'anonymous'; // Helps with certain browser stream policies document.body.appendChild(audio); - console.log('๐Ÿ†• Created fresh media element (audio) for listener'); + console.log('NEW Created fresh media element (audio) for listener'); - // MP3 stream (server-side) โ€” requires ffmpeg on the server. + // Stall Watchdog Variables + let lastCheckedTime = 0; + let stallCount = 0; + let watchdogInterval = null; + + const stopWatchdog = () => { + if (watchdogInterval) { + clearInterval(watchdogInterval); + watchdogInterval = null; + } + }; + + const startWatchdog = () => { + stopWatchdog(); + lastCheckedTime = audio.currentTime; + stallCount = 0; + + watchdogInterval = setInterval(() => { + if (!window.listenerAudioEnabled || audio.paused) return; + + if (audio.currentTime === lastCheckedTime && audio.currentTime > 0) { + stallCount++; + console.warn(`[WARN] Stream stall detected (${stallCount}/3)...`); + + if (stallCount >= 3) { // 3 cycles * 2s = 6s of stalling + console.error('ALERT Stream is completely stalled. Force reconnecting...'); + reconnectStream(); + stallCount = 0; + } + } else { + stallCount = 0; + } + lastCheckedTime = audio.currentTime; + }, 2000); + }; + + const reconnectStream = () => { + if (!window.listenerAudioEnabled || !window.listenerAudio) return; + + console.log('RECONNECTING Reconnecting stream...'); + const statusEl = document.getElementById('connection-status'); + if (statusEl) { + statusEl.textContent = '[WAIT] Connection weak - Reconnecting...'; + statusEl.classList.remove('glow-text'); + } + + const wasPaused = window.listenerAudio.paused; + // Bust cache with timestamp + window.listenerAudio.src = getMp3FallbackUrl() + '?t=' + Date.now(); + window.listenerAudio.load(); + + if (!wasPaused) { + window.listenerAudio.play() + .then(() => { + if (statusEl) { + statusEl.textContent = '[ACTIVE] Reconnected'; + statusEl.classList.add('glow-text'); + } + // Re-sync visualiser + startListenerVUMeter(); + }) + .catch(e => console.warn('Reconnect play failed:', e)); + } + }; + + // MP3 stream (server-side) - requires ffmpeg on the server. audio.src = getMp3FallbackUrl(); audio.load(); - console.log(`๐ŸŽง Listener source set to MP3 stream: ${audio.src}`); + console.log(`STREAMPANEL Listener source set to MP3 stream: ${audio.src}`); + + // Auto-reconnect logic if stream errors out + audio.onerror = () => { + if (!window.listenerAudioEnabled) return; + console.error('[ERROR] Audio stream error!'); + reconnectStream(); + }; + + audio.onplay = () => { + console.log('PLAY Stream playing'); + startWatchdog(); + const statusEl = document.getElementById('connection-status'); + if (statusEl) statusEl.classList.add('glow-text'); + }; + + audio.onpause = () => { + console.log('PAUSE Stream paused'); + stopWatchdog(); + }; // Show enable audio button instead of attempting autoplay const enableAudioBtn = document.getElementById('enable-audio-btn'); @@ -2340,7 +2651,7 @@ function initListenerMode() { enableAudioBtn.style.display = 'flex'; } if (statusEl) { - statusEl.textContent = '๐Ÿ”ต Click "Enable Audio" to start listening (MP3)'; + statusEl.textContent = '[INFO] Click "Enable Audio" to start listening (MP3)'; } // Store audio element and context for later activation @@ -2391,20 +2702,22 @@ function initListenerMode() { const statusEl = document.getElementById('connection-status'); // Only update if audio is enabled, otherwise keep the "Click Enable Audio" message if (statusEl && window.listenerAudioEnabled) { - statusEl.textContent = '๐ŸŸข Connected'; + statusEl.textContent = '[ACTIVE] Connected'; } + // Re-join as listener on reconnect so server tracks us + socket.emit('join_listener'); }); socket.on('disconnect', () => { const statusEl = document.getElementById('connection-status'); // Always show disconnect status as it's critical - if (statusEl) statusEl.textContent = '๐Ÿ”ด Disconnected'; + if (statusEl) statusEl.textContent = '[OFFLINE] Disconnected'; }); } // Enable audio for listener mode (called when user clicks the button) async function enableListenerAudio() { - console.log('๐ŸŽง Enabling audio via user gesture...'); + console.log('STREAMPANEL Enabling audio via user gesture...'); const enableAudioBtn = document.getElementById('enable-audio-btn'); const statusEl = document.getElementById('connection-status'); @@ -2421,7 +2734,7 @@ async function enableListenerAudio() { // 2. Resume audio context (CRITICAL for Chrome/Safari) if (listenerAudioContext.state === 'suspended') { await listenerAudioContext.resume(); - console.log('โœ… Audio context resumed'); + console.log('[OK] Audio context resumed'); } // 3. Bridge Audio Element to AudioContext if not already connected @@ -2462,7 +2775,7 @@ async function enableListenerAudio() { // 4. Prepare and start audio playback if (window.listenerAudio) { - console.log('๐Ÿ“Š Audio element state:', { + console.log('[DATA] Audio element state:', { readyState: window.listenerAudio.readyState, networkState: window.listenerAudio.networkState, src: window.listenerAudio.src ? 'set' : 'not set', @@ -2483,9 +2796,23 @@ async function enableListenerAudio() { return window.listenerAudio.buffered && window.listenerAudio.buffered.length > 0; }; + // Mark audio as enabled immediately so reconnection logic is active + window.listenerAudioEnabled = true; + // MP3 stream: call play() immediately to capture the user gesture. if (audioText) audioText.textContent = 'STARTING...'; console.log('Attempting to play audio...'); + + // Create a timeout for the play promise to prevent indefinite 'buffering' state + const playTimeout = setTimeout(() => { + if (!hasBufferedData()) { + console.warn('[WARN] Audio play is taking a long time (buffering)...'); + if (audioText) audioText.textContent = 'STILL BUFFERING...'; + // Trigger a load() to nudge the browser if it's stuck + window.listenerAudio.load(); + } + }, 8000); + const playPromise = window.listenerAudio.play(); // If not buffered yet, show buffering but don't block. @@ -2493,11 +2820,14 @@ async function enableListenerAudio() { audioText.textContent = 'BUFFERING...'; } - await playPromise; - console.log('โœ… Audio playback started successfully'); - - // Mark audio as enabled so status updates can now display - window.listenerAudioEnabled = true; + try { + await playPromise; + clearTimeout(playTimeout); + console.log('[OK] Audio playback started successfully'); + } catch (e) { + clearTimeout(playTimeout); + throw e; // Re-throw to be caught by the outer catch block + } } // 4. Hide the button and update status @@ -2509,12 +2839,12 @@ async function enableListenerAudio() { } if (statusEl) { - statusEl.textContent = '๐ŸŸข Audio Active - Enjoy the stream'; + statusEl.textContent = '[ACTIVE] Audio Active - Enjoy the stream'; statusEl.classList.add('glow-text'); } } catch (error) { - console.error('โŒ Failed to enable audio:', error); + console.error('[ERROR] Failed to enable audio:', error); const stashedStatus = document.getElementById('connection-status'); const stashedBtn = document.getElementById('enable-audio-btn'); const audioText = stashedBtn ? stashedBtn.querySelector('.audio-text') : null; @@ -2572,20 +2902,35 @@ function monitorTrackEnd() { // If end reached (with 0.5s buffer for safety) if (remaining <= 0.5) { - // Don't pause during broadcast - let the track end naturally + // During broadcast, still handle auto-play/queue to avoid dead air if (isBroadcasting) { - console.log(`Track ending during broadcast on Deck ${id} - continuing stream`); + console.log(`Track ending during broadcast on Deck ${id}`); if (settings[`repeat${id}`]) { - console.log(`๐Ÿ” Repeating track on Deck ${id}`); + console.log(`LOOP Repeating track on Deck ${id}`); seekTo(id, 0); + return; } - // Skip pause/stop during broadcast to maintain stream + // Auto-play from queue during broadcast to maintain stream + if (settings.autoPlay && queues[id] && queues[id].length > 0) { + decks[id].loading = true; + console.log(`Auto-play (broadcast): Loading next from Queue ${id}...`); + const next = queues[id].shift(); + renderQueue(id); + loadFromServer(id, next.file, next.title).then(() => { + decks[id].loading = false; + playDeck(id); + }).catch(() => { + decks[id].loading = false; + }); + return; + } + // No repeat, no queue - just let the stream continue silently return; } if (settings[`repeat${id}`]) { // Full song repeat - console.log(`๐Ÿ” Repeating track on Deck ${id}`); + console.log(`LOOP Repeating track on Deck ${id}`); seekTo(id, 0); } else if (settings.autoPlay) { // Prevent race condition @@ -2690,7 +3035,11 @@ function resetDeck(id) { // Redraw waveform to clear cue/loop markers drawWaveform(id); - console.log(`โœ… Deck ${id} reset complete!`); + // Clear neon glow + document.body.classList.remove('playing-' + id); + + console.log(`[OK] Deck ${id} reset complete!`); + // Visual feedback const resetBtn = document.querySelector(`#deck-${id} .reset-btn`); @@ -2801,7 +3150,7 @@ function renderQueue(deckId) { const loadBtn = document.createElement('button'); loadBtn.className = 'queue-load-btn'; - loadBtn.textContent = 'โ–ถ'; + loadBtn.textContent = 'PLAY'; loadBtn.title = 'Load now'; loadBtn.onclick = (e) => { e.stopPropagation(); @@ -2811,7 +3160,7 @@ function renderQueue(deckId) { const removeBtn = document.createElement('button'); removeBtn.className = 'queue-remove-btn'; - removeBtn.textContent = 'โœ•'; + removeBtn.textContent = 'X'; removeBtn.title = 'Remove from queue'; removeBtn.onclick = (e) => { e.stopPropagation(); @@ -2870,15 +3219,7 @@ function renderQueue(deckId) { }); } -// Auto-load next track when current track ends -function checkAndLoadNextFromQueue(deckId) { - if (settings.autoPlay && queues[deckId].length > 0) { - console.log(`Auto-loading next track from Queue ${deckId}...`); - setTimeout(() => { - loadNextFromQueue(deckId); - }, 500); - } -} + // ========================================== // KEYBOARD SHORTCUTS SYSTEM // ========================================== @@ -2936,7 +3277,7 @@ async function loadKeyboardMappings() { const data = await response.json(); if (data.success && data.keymaps) { keyboardMappings = data.keymaps; - console.log('โœ… Loaded custom keyboard mappings from server'); + console.log('[OK] Loaded custom keyboard mappings from server'); } else { console.log('Using default keyboard mappings'); } @@ -2953,7 +3294,7 @@ async function saveKeyboardMappings() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(keyboardMappings) }); - console.log('๐Ÿ’พ Saved keyboard mappings to server'); + console.log('[SAVED] Saved keyboard mappings to server'); } catch (e) { console.error('Failed to save keyboard mappings to server:', e); } @@ -3038,7 +3379,7 @@ document.addEventListener('keydown', (e) => { if (mapping) { e.preventDefault(); - console.log(`Keyboard: ${key} โ†’ ${mapping.label}`); + console.log(`Keyboard: ${key} RIGHT ${mapping.label}`); executeKeyboardAction(mapping.action); } }); @@ -3075,7 +3416,7 @@ function createKeyboardSettingsPanel() { panel.innerHTML = `

Keyboard Shortcuts

- +

Click on a key to reassign it. Press ESC to cancel.

@@ -3108,9 +3449,9 @@ function renderKeyboardMappings() { item.className = 'keyboard-mapping-item'; item.innerHTML = ` ${formatKeyName(key)} - โ†’ + RIGHT ${mapping.label} - + `; list.appendChild(item); }); @@ -3119,10 +3460,10 @@ function renderKeyboardMappings() { // Format key name for display function formatKeyName(key) { const names = { - 'ArrowLeft': 'โ† Left', - 'ArrowRight': 'โ†’ Right', - 'ArrowUp': 'โ†‘ Up', - 'ArrowDown': 'โ†“ Down', + 'ArrowLeft': 'LEFT Left', + 'ArrowRight': 'RIGHT Right', + 'ArrowUp': 'UP Up', + 'ArrowDown': 'DOWN Down', 'Escape': 'ESC', ' ': 'Space' }; @@ -3130,8 +3471,8 @@ function formatKeyName(key) { } // Reassign a key -function reassignKey(oldKey) { - const item = event.target.closest('.keyboard-mapping-item'); +function reassignKey(oldKey, evt) { + const item = evt.target.closest('.keyboard-mapping-item'); item.classList.add('listening'); item.querySelector('.key-reassign-btn').textContent = 'Press new key...'; @@ -3166,7 +3507,7 @@ function reassignKey(oldKey) { renderKeyboardMappings(); document.removeEventListener('keydown', listener); - console.log(`โœ… Remapped: ${formatKeyName(oldKey)} โ†’ ${formatKeyName(newKey)}`); + console.log(`[OK] Remapped: ${formatKeyName(oldKey)} RIGHT ${formatKeyName(newKey)}`); }; document.addEventListener('keydown', listener); @@ -3207,9 +3548,9 @@ function importKeyboardMappings() { keyboardMappings = imported; saveKeyboardMappings(); renderKeyboardMappings(); - alert('โœ… Keyboard mappings imported successfully!'); + alert('[OK] Keyboard mappings imported successfully!'); } catch (err) { - alert('โŒ Failed to import: Invalid file format'); + alert('[ERROR] Failed to import: Invalid file format'); } }; reader.readAsText(file); diff --git a/server.py b/server.py index e3f9a17..2d3e665 100644 --- a/server.py +++ b/server.py @@ -44,29 +44,30 @@ listener_sids = set() dj_sids = set() # === Optional MP3 fallback stream (server-side transcoding) === -# This allows listeners on browsers that don't support WebM/Opus via MediaSource -# (notably some Safari / locked-down environments) to still hear the stream. _ffmpeg_proc = None -_ffmpeg_in_q = queue.Queue(maxsize=40) +_ffmpeg_in_q = queue.Queue(maxsize=20) # Optimized for low-latency live streaming _current_bitrate = "192k" _mp3_clients = set() # set[queue.Queue] _mp3_lock = threading.Lock() -_transcode_threads_started = False _transcoder_bytes_out = 0 _transcoder_last_error = None _last_audio_chunk_ts = 0.0 -_mp3_preroll = collections.deque(maxlen=256) # Pre-roll (~10s at 192k with 1KB chunks) +_mp3_preroll = collections.deque(maxlen=512) # Larger pre-roll (~512KB) def _start_transcoder_if_needed(is_mp3_input=False): - global _ffmpeg_proc, _transcode_threads_started, _transcoder_last_error + global _ffmpeg_proc, _transcoder_last_error - # If already running, check if we need to restart for mode change + # If already running, check if we need to restart if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None: return - # Local broadcast mode: input from pipe (Relay mode removed) - # If input is already MP3, we just use 'copy' to avoid double-encoding + # Ensure stale process is cleaned up + if _ffmpeg_proc: + try: _ffmpeg_proc.terminate() + except: pass + _ffmpeg_proc = None + codec = 'copy' if is_mp3_input else 'libmp3lame' cmd = [ @@ -92,7 +93,6 @@ def _start_transcoder_if_needed(is_mp3_input=False): cmd.extend(['-b:a', _current_bitrate]) cmd.extend([ - '-tune', 'zerolatency', '-flush_packets', '1', '-f', 'mp3', 'pipe:1', @@ -108,60 +108,58 @@ def _start_transcoder_if_needed(is_mp3_input=False): ) except FileNotFoundError: _ffmpeg_proc = None - print('โš ๏ธ ffmpeg not found; /stream.mp3 fallback disabled') + print('WARNING: ffmpeg not found; /stream.mp3 fallback disabled') return mode_str = "PASSTHROUGH (copy)" if is_mp3_input else f"TRANSCODE ({_current_bitrate})" - print(f'๐ŸŽ›๏ธ ffmpeg transcoder started for /stream.mp3 ({mode_str})') + print(f'INFO: ffmpeg transcoder started ({mode_str})') - # Reset error state _transcoder_last_error = None - # Always ensure threads are running if we just started/restarted the process - # Clear the input queue to avoid old data being sent to new process + # Clear queue to avoid stale data while not _ffmpeg_in_q.empty(): try: _ffmpeg_in_q.get_nowait() except: break - def _writer(): - global _transcoder_last_error, _transcode_threads_started - print("๐Ÿงต Transcoder writer thread started") - while True: + # Define threads INSIDE so they close over THIS specific 'proc' + def _writer(proc): + global _transcoder_last_error + print(f"[THREAD] Transcoder writer started (PID: {proc.pid})") + while proc.poll() is None: try: - chunk = _ffmpeg_in_q.get(timeout=2) - if chunk is None: - break - proc = _ffmpeg_proc - if proc is None or proc.stdin is None or proc.poll() is not None: - continue - proc.stdin.write(chunk) - proc.stdin.flush() + chunk = _ffmpeg_in_q.get(timeout=1.0) + if chunk is None: break + + if proc.stdin: + proc.stdin.write(chunk) + proc.stdin.flush() except queue.Empty: - if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: - break continue - except Exception as e: - if _ffmpeg_proc is not None: - print(f"โš ๏ธ Transcoder writer error: {e}") - _transcoder_last_error = f'stdin write failed: {e}' + except (BrokenPipeError, ConnectionResetError): + _transcoder_last_error = "Broken pipe in writer" break - _transcode_threads_started = False - print("๐Ÿงต Transcoder writer thread exiting") + except Exception as e: + print(f"WARNING: Transcoder writer error: {e}") + _transcoder_last_error = str(e) + break + + # Ensure process is killed if thread exits unexpectedly + if proc.poll() is None: + try: proc.terminate() + except: pass + print(f"[THREAD] Transcoder writer finished (PID: {proc.pid})") - def _reader(): - global _transcoder_bytes_out, _transcoder_last_error, _transcode_threads_started - print("๐Ÿงต Transcoder reader thread started") - proc = _ffmpeg_proc - while proc and proc.poll() is None: + def _reader(proc): + global _transcoder_bytes_out, _transcoder_last_error + print(f"[THREAD] Transcoder reader started (PID: {proc.pid})") + while proc.poll() is None: try: # Smaller read for smoother delivery (1KB) # This prevents buffering delays at lower bitrates data = proc.stdout.read(1024) - if not data: - break + if not data: break _transcoder_bytes_out += len(data) - # Store in pre-roll with _mp3_lock: _mp3_preroll.append(data) clients = list(_mp3_clients) @@ -169,87 +167,180 @@ def _start_transcoder_if_needed(is_mp3_input=False): for q in clients: try: q.put_nowait(data) - except Exception: + except queue.Full: + # Client is too slow, skip this chunk for them pass except Exception as e: - print(f"โš ๏ธ Transcoder reader error: {e}") - _transcoder_last_error = f'stdout read failed: {e}' + print(f"WARNING: Transcoder reader error: {e}") + _transcoder_last_error = str(e) break - _transcode_threads_started = False - print("๐Ÿงต Transcoder reader thread exiting") - if not _transcode_threads_started: - _transcode_threads_started = True - threading.Thread(target=_writer, daemon=True).start() - threading.Thread(target=_reader, daemon=True).start() + # Ensure process is killed if thread exits unexpectedly + if proc.poll() is None: + try: proc.terminate() + except: pass + print(f"[THREAD] Transcoder reader finished (PID: {proc.pid})") + + # Start greenlets/threads for THIS process specifically + eventlet.spawn(_writer, _ffmpeg_proc) + eventlet.spawn(_reader, _ffmpeg_proc) def _stop_transcoder(): - global _ffmpeg_proc, _transcode_threads_started - try: - _ffmpeg_in_q.put_nowait(None) - except Exception: - pass + global _ffmpeg_proc + print("STOPPING: Transcoder process") + + # Signal threads to stop via the queue + try: _ffmpeg_in_q.put_nowait(None) + except: pass + # Shutdown the process proc = _ffmpeg_proc _ffmpeg_proc = None - # Reset thread flag so they can be re-launched if needed - # (The existing threads will exit cleanly on None/EOF) - _transcode_threads_started = False - _mp3_preroll.clear() - - if proc is None: - return - - # Signal all listening clients to finish their stream + if proc: + try: + proc.terminate() + # Drain stdout/stderr to satisfy OS buffers + proc.communicate(timeout=1.0) + except: + try: proc.kill() + except: pass + + # Clear client state with _mp3_lock: clients = list(_mp3_clients) for q in clients: - try: - q.put_nowait(None) - except: - pass + try: q.put_nowait(None) + except: pass _mp3_clients.clear() - - try: - proc.terminate() - except Exception: - pass + _mp3_preroll.clear() def _feed_transcoder(data: bytes): global _last_audio_chunk_ts if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: - return + # If active but dead, restart it automatically + if broadcast_state.get('active'): + _start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False)) + else: + return + _last_audio_chunk_ts = time.time() try: _ffmpeg_in_q.put_nowait(data) - except Exception: - # Queue full; drop to keep latency bounded. + except queue.Full: + # Drop chunk if overflow to prevent memory bloat pass -MUSIC_FOLDER = "music" +# Load settings to get MUSIC_FOLDER +def _load_settings(): + try: + if os.path.exists('settings.json'): + with open('settings.json', 'r', encoding='utf-8') as f: + return json.load(f) + except: + pass + return {} + +SETTINGS = _load_settings() +MUSIC_FOLDER = SETTINGS.get('library', {}).get('music_folder', 'music') + # Ensure music folder exists if not os.path.exists(MUSIC_FOLDER): - os.makedirs(MUSIC_FOLDER) + try: + os.makedirs(MUSIC_FOLDER) + except: + # Fallback to default if custom path fails + MUSIC_FOLDER = "music" + if not os.path.exists(MUSIC_FOLDER): + os.makedirs(MUSIC_FOLDER) # Helper for shared routes def setup_shared_routes(app): @app.route('/library.json') def get_library(): library = [] + global MUSIC_FOLDER if os.path.exists(MUSIC_FOLDER): - for filename in sorted(os.listdir(MUSIC_FOLDER)): - if filename.lower().endswith(('.mp3', '.m4a', '.wav', '.flac', '.ogg')): - library.append({ - "title": os.path.splitext(filename)[0], - "file": f"music/{filename}" - }) + # Recursively find music files if desired, or stay top-level. + # The prompt says "choose which folder", so maybe top-level of that folder is fine. + for root, dirs, files in os.walk(MUSIC_FOLDER): + for filename in sorted(files): + if filename.lower().endswith(('.mp3', '.m4a', '.wav', '.flac', '.ogg')): + rel_path = os.path.relpath(os.path.join(root, filename), MUSIC_FOLDER) + library.append({ + "title": os.path.splitext(filename)[0], + "file": f"music_proxy/{rel_path}" + }) + break # Only top level for now to keep it simple, or remove break for recursive return jsonify(library) + @app.route('/music_proxy/') + def music_proxy(filename): + return send_from_directory(MUSIC_FOLDER, filename) + + @app.route('/browse_directories', methods=['GET']) + def browse_directories(): + path = request.args.get('path', os.path.expanduser('~')) + try: + entries = [] + if os.path.exists(path) and os.path.isdir(path): + # Add parent + parent = os.path.dirname(os.path.abspath(path)) + entries.append({"name": "..", "path": parent, "isDir": True}) + + for item in sorted(os.listdir(path)): + full_path = os.path.join(path, item) + if os.path.isdir(full_path) and not item.startswith('.'): + entries.append({ + "name": item, + "path": full_path, + "isDir": True + }) + return jsonify({"success": True, "path": os.path.abspath(path), "entries": entries}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/update_settings', methods=['POST']) + def update_settings(): + try: + data = request.get_json() + # Load existing + settings = _load_settings() + + # Update selectively + if 'library' not in settings: settings['library'] = {} + if 'music_folder' in data.get('library', {}): + new_folder = data['library']['music_folder'] + if os.path.exists(new_folder) and os.path.isdir(new_folder): + settings['library']['music_folder'] = new_folder + global MUSIC_FOLDER + MUSIC_FOLDER = new_folder + else: + return jsonify({"success": False, "error": "Invalid folder path"}), 400 + + with open('settings.json', 'w', encoding='utf-8') as f: + json.dump(settings, f, indent=4) + + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/') def serve_static(filename): + # Block access to sensitive files + blocked = ('.py', '.pyc', '.env', '.json', '.sh', '.bak', '.log', '.pem', '.key') + # Allow specific safe JSON/JS/CSS files + allowed_extensions = ('.css', '.js', '.html', '.htm', '.png', '.jpg', '.jpeg', + '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.map') + if filename.endswith(blocked) and not filename.endswith(('.css', '.js')): + from flask import abort + abort(403) + # Prevent path traversal + if '..' in filename or filename.startswith('/'): + from flask import abort + abort(403) response = send_from_directory('.', filename) if filename.endswith(('.css', '.js', '.html')): response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' @@ -269,24 +360,26 @@ def setup_shared_routes(app): if file.filename == '': return jsonify({"success": False, "error": "No file selected"}), 400 - if not file.filename.endswith('.mp3'): - return jsonify({"success": False, "error": "Only MP3 files are allowed"}), 400 + allowed_exts = ('.mp3', '.m4a', '.wav', '.flac', '.ogg') + ext = os.path.splitext(file.filename)[1].lower() + if ext not in allowed_exts: + return jsonify({"success": False, "error": f"Supported formats: {', '.join(allowed_exts)}"}), 400 # Sanitize filename (keep extension) import re name_without_ext = os.path.splitext(file.filename)[0] name_without_ext = re.sub(r'[^\w\s-]', '', name_without_ext) - name_without_ext = re.sub(r'[-\s]+', '-', name_without_ext) - filename = f"{name_without_ext}.mp3" + name_without_ext = re.sub(r'\s+', ' ', name_without_ext).strip() + filename = f"{name_without_ext}{ext}" filepath = os.path.join(MUSIC_FOLDER, filename) try: file.save(filepath) - print(f"โœ… Uploaded: {filename}") + print(f"UPLOADED: {filename}") return jsonify({"success": True, "filename": filename}) except Exception as e: - print(f"โŒ Upload error: {e}") + print(f"ERROR: Upload error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/save_keymaps', methods=['POST']) @@ -295,10 +388,10 @@ def setup_shared_routes(app): data = request.get_json() with open('keymaps.json', 'w', encoding='utf-8') as f: json.dump(data, f, indent=4) - print("๐Ÿ’พ Keymaps saved to keymaps.json") + print("SAVED: Keymaps saved to keymaps.json") return jsonify({"success": True}) except Exception as e: - print(f"โŒ Save keymaps error: {e}") + print(f"ERROR: Save keymaps error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/load_keymaps', methods=['GET']) @@ -311,7 +404,7 @@ def setup_shared_routes(app): else: return jsonify({"success": True, "keymaps": None}) except Exception as e: - print(f"โŒ Load keymaps error: {e}") + print(f"ERROR: Load keymaps error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/stream.mp3') @@ -321,8 +414,8 @@ def setup_shared_routes(app): if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: return jsonify({"success": False, "error": "MP3 stream not available"}), 503 - print(f"๐Ÿ‘‚ New listener joined stream (Bursting {_mp3_preroll.maxlen} frames)") - client_q: queue.Queue = queue.Queue(maxsize=100) + print(f"LISTENER: New listener joined stream (Bursting {_mp3_preroll.maxlen} frames)") + client_q: queue.Queue = queue.Queue(maxsize=500) with _mp3_lock: # Burst pre-roll to new client so they start playing instantly for chunk in _mp3_preroll: @@ -335,13 +428,20 @@ def setup_shared_routes(app): def gen(): try: while True: - chunk = client_q.get() + try: + chunk = client_q.get(timeout=30) + except queue.Empty: + # No data for 30s - check if broadcast is still active + if not broadcast_state.get('active'): + break + continue if chunk is None: break yield chunk finally: with _mp3_lock: _mp3_clients.discard(client_q) + print(f"LISTENER: Listener disconnected from stream") return Response(stream_with_context(gen()), content_type='audio/mpeg', headers={ 'Cache-Control': 'no-cache, no-store, must-revalidate', @@ -482,15 +582,15 @@ dj_socketio = SocketIO( @dj_socketio.on('connect') def dj_connect(): if DJ_AUTH_ENABLED and session.get('dj_authed') is not True: - print(f"โ›” DJ socket rejected (unauthorized): {request.sid}") + print(f"REJECTED: DJ socket rejected (unauthorized): {request.sid}") return False - print(f"๐ŸŽง DJ connected: {request.sid}") + print(f"STREAMPANEL: DJ connected: {request.sid}") dj_sids.add(request.sid) @dj_socketio.on('disconnect') def dj_disconnect(): dj_sids.discard(request.sid) - print("โš ๏ธ DJ disconnected - broadcast will continue until manually stopped") + print("WARNING: DJ disconnected - broadcast will continue until manually stopped") def stop_broadcast_after_timeout(): """No longer used - broadcasts don't auto-stop""" @@ -500,19 +600,21 @@ def stop_broadcast_after_timeout(): def dj_start(data=None): broadcast_state['active'] = True session['is_dj'] = True - print("๐ŸŽ™๏ธ Broadcast -> ACTIVE") + print("BROADCAST: ACTIVE") is_mp3_input = False if data: if 'bitrate' in data: global _current_bitrate _current_bitrate = data['bitrate'] - print(f"๐Ÿ“ก Setting stream bitrate to: {_current_bitrate}") + print(f"BITRATE: Setting stream bitrate to: {_current_bitrate}") if data.get('format') == 'mp3': is_mp3_input = True + broadcast_state['is_mp3_input'] = is_mp3_input # Clear pre-roll for fresh start - _mp3_preroll.clear() + with _mp3_lock: + _mp3_preroll.clear() _start_transcoder_if_needed(is_mp3_input=is_mp3_input) @@ -527,16 +629,13 @@ def dj_get_listener_count(): def dj_stop(): broadcast_state['active'] = False session['is_dj'] = False - print("๐Ÿ›‘ DJ stopped broadcasting") + print("STOPPED: DJ stopped broadcasting") _stop_transcoder() listener_socketio.emit('broadcast_stopped', namespace='/') listener_socketio.emit('stream_status', {'active': False}, namespace='/') - listener_socketio.emit('broadcast_stopped', namespace='/') - listener_socketio.emit('stream_status', {'active': False}, namespace='/') - @dj_socketio.on('audio_chunk') def dj_audio(data): # MP3-only mode: do not relay raw chunks to listeners; feed transcoder only. @@ -554,6 +653,15 @@ def dj_audio(data): listener_app = Flask(__name__, static_folder='.', static_url_path='') listener_app.config['SECRET_KEY'] = 'listener_secret' setup_shared_routes(listener_app) + +# Block write/admin endpoints on the listener server +@listener_app.before_request +def _restrict_listener_routes(): + """Prevent listeners from accessing DJ-only write endpoints.""" + blocked_paths = ('/update_settings', '/upload', '/save_keymaps', '/browse_directories') + if request.path in blocked_paths: + from flask import abort + abort(403) listener_socketio = SocketIO( listener_app, cors_allowed_origins="*", @@ -567,13 +675,13 @@ listener_socketio = SocketIO( @listener_socketio.on('connect') def listener_connect(): - print(f"๐Ÿ‘‚ Listener Socket Connected: {request.sid}") + print(f"LISTENER: Listener Socket Connected: {request.sid}") @listener_socketio.on('disconnect') def listener_disconnect(): listener_sids.discard(request.sid) count = len(listener_sids) - print(f"โŒ Listener left. Total: {count}") + print(f"REMOVED: Listener left. Total: {count}") # Notify BOTH namespaces listener_socketio.emit('listener_count', {'count': count}, namespace='/') dj_socketio.emit('listener_count', {'count': count}, namespace='/') @@ -583,7 +691,7 @@ def listener_join(): if request.sid not in listener_sids: listener_sids.add(request.sid) count = len(listener_sids) - print(f"๐Ÿ‘‚ New listener joined. Total: {count}") + print(f"LISTENER: New listener joined. Total: {count}") listener_socketio.emit('listener_count', {'count': count}, namespace='/') dj_socketio.emit('listener_count', {'count': count}, namespace='/') @@ -594,13 +702,16 @@ def listener_get_count(): emit('listener_count', {'count': len(listener_sids)}) # DJ Panel Routes (No engine commands needed in local mode) -@dj_socketio.on('get_mixer_status') -def get_mixer_status(): - pass +def _transcoder_watchdog(): + """Periodic check to ensure the transcoder stays alive during active broadcasts.""" + while True: + if broadcast_state.get('active'): + if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: + # Only log if it's actually dead and supposed to be alive + print("WARNING: Watchdog: Transcoder dead during active broadcast, reviving...") + _start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False)) + eventlet.sleep(5) -@dj_socketio.on('audio_sync_queue') -def audio_sync_queue(data): - pass def _listener_count_sync_loop(): """Periodic background sync to ensure listener count is always accurate.""" @@ -613,20 +724,21 @@ def _listener_count_sync_loop(): if __name__ == '__main__': print("=" * 50) - print("๐ŸŽง TECHDJ PRO - DUAL PORT ARCHITECTURE") + print("TECHDJ PRO - DUAL PORT ARCHITECTURE") print("=" * 50) # Ports from environment or defaults dj_port = int(os.environ.get('DJ_PORT', 5000)) listen_port = int(os.environ.get('LISTEN_PORT', 5001)) - print(f"๐Ÿ‘‰ DJ PANEL API: http://0.0.0.0:{dj_port}") - print(f"๐Ÿ‘‰ LISTEN PAGE: http://0.0.0.0:{listen_port}") + print(f"URL: DJ PANEL API: http://0.0.0.0:{dj_port}") + print(f"URL: LISTEN PAGE: http://0.0.0.0:{listen_port}") print("=" * 50) # Audio engine DISABLED - print(f"โœ… Local Radio server ready on ports {dj_port} & {listen_port}") + print(f"READY: Local Radio server ready on ports {dj_port} & {listen_port}") # Run both servers using eventlet's spawn eventlet.spawn(_listener_count_sync_loop) + eventlet.spawn(_transcoder_watchdog) eventlet.spawn(dj_socketio.run, dj_app, host='0.0.0.0', port=dj_port, debug=False) listener_socketio.run(listener_app, host='0.0.0.0', port=listen_port, debug=False) diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..b797fd8 --- /dev/null +++ b/settings.json @@ -0,0 +1,22 @@ +{ + "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" + }, + "audio": { + "recording_sample_rate": 48000, + "recording_format": "wav", + "stream_server_url": "http://54.37.246.24:5000" + }, + "ui": { + "neon_mode": 2 + }, + "library": { + "auto_scan": true, + "yt_default_format": "mp3" + } +} \ No newline at end of file diff --git a/style.css b/style.css index b66ae89..ed7ae3a 100644 --- a/style.css +++ b/style.css @@ -42,26 +42,36 @@ body { } body::before { - display: none !important; - /* Completely disabled to prevent UI blocking */ + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 10000; + opacity: var(--glow-opacity, 0.3); + transition: all 1s cubic-bezier(0.4, 0, 0.2, 1); } body.playing-A::before { - display: none !important; + box-shadow: inset 0 0 var(--glow-spread, 30px) var(--primary-cyan); } body.playing-B::before { - display: none !important; + box-shadow: inset 0 0 var(--glow-spread, 30px) var(--secondary-magenta); } body.playing-A.playing-B::before { - display: none !important; + box-shadow: + inset 0 0 var(--glow-spread, 30px) var(--primary-cyan), + inset 0 0 calc(var(--glow-spread, 30px) * 1.5) var(--secondary-magenta); } body.listener-glow::before { - display: none !important; + animation: pulse-listener 4s ease-in-out infinite; + z-index: 1; + /* Keep behind listener elements */ } + @keyframes pulse-listener { 0%, @@ -127,6 +137,54 @@ body.listener-glow::before { box-shadow: 0 0 30px var(--primary-cyan); } +/* MOBILE TOP BAR */ +.mobile-top-bar { + display: none; + height: 44px; + background: rgba(10, 10, 20, 0.95); + border-bottom: 1px solid rgba(0, 243, 255, 0.2); + align-items: center; + justify-content: space-between; + padding: 0 15px; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 10002; + backdrop-filter: blur(10px); +} + +.status-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.7rem; + color: var(--primary-cyan); + font-family: 'Orbitron', sans-serif; +} + +.status-dot { + width: 6px; + height: 6px; + background: #00ff00; + border-radius: 50%; + box-shadow: 0 0 8px #00ff00; +} + +.app-logo { + font-family: 'Orbitron', sans-serif; + font-weight: bold; + font-size: 0.9rem; + letter-spacing: 2px; + color: #fff; +} + +.clock-display { + font-family: 'Orbitron', sans-serif; + font-size: 0.8rem; + color: var(--text-dim); +} + header { display: flex; justify-content: space-between; @@ -964,10 +1022,10 @@ input[type=range] { transform: rotate(360deg) scale(0.95); } -/* Transport Buttons - Make room for 4 buttons */ +/* Transport Buttons - Make room for 5 buttons */ .transport { display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-columns: repeat(5, 1fr); gap: 8px; margin-top: 10px; } @@ -1630,26 +1688,40 @@ input[type=range] { overflow: hidden; } - /* Disable all edge glow effects on mobile */ - body::before, - body.playing-A::before, - body.playing-B::before, - body.playing-A.playing-B::before { - display: none !important; + /* Glow intensity is managed via --glow-opacity variable */ + body::before { + opacity: calc(var(--glow-opacity) * 0.5); + /* Less intense on mobile */ } - .app-container { - grid-template-columns: 1fr; - grid-template-rows: 1fr; - gap: 0; - padding: 0; - padding-bottom: 58px; - height: 100vh; - overflow: hidden; + + .mobile-top-bar { + display: flex; } .mobile-tabs { display: flex; + box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.9); + } + + /* Adjust app container for top and bottom bars */ + .app-container { + grid-template-columns: 1fr; + grid-template-rows: 1fr; + gap: 0; + padding: 44px 0 58px 0 !important; + height: 100vh; + overflow: hidden; + } + + .pc-only { + display: none !important; + } + + /* Hide "RESET" and "integrated queue" on mobile decks to reduce clutter */ + .mob-hide, + .deck-queue { + display: none !important; } .library-section, @@ -1733,13 +1805,19 @@ input[type=range] { } .waveform-container { - height: 70px !important; - margin-bottom: 10px !important; - padding: 4px !important; + height: 60px !important; + margin-bottom: 8px !important; + padding: 3px !important; } .waveform-canvas { - height: 65px !important; + height: 55px !important; + } + + #viz-A, + #viz-B { + height: 50px !important; + margin-bottom: 5px !important; } .controls-grid { @@ -2773,6 +2851,11 @@ body.listening-active .landscape-prompt { display: none !important; } + /* Hide the pitch/tempo fader-group (all children hidden) */ + .controls-grid > .fader-group:last-child { + display: none !important; + } + /* Ultra-compact spacing */ .transport { gap: 4px !important; @@ -2878,8 +2961,8 @@ body.listening-active .landscape-prompt { } .volume-fader { - height: 180px !important; - width: 45px !important; + height: 100px !important; + width: 40px !important; } /* Better transport button spacing */ @@ -2898,16 +2981,16 @@ body.listening-active .landscape-prompt { @media (max-width: 768px) { - /* Even larger for phones */ + /* Slightly larger disk for tablets */ .dj-disk { - width: 200px; - height: 200px; + width: 130px; + height: 130px; } .disk-label { - width: 70px; - height: 70px; - font-size: 1.8rem; + width: 50px; + height: 50px; + font-size: 1.4rem; } /* Stack controls vertically */ @@ -2915,42 +2998,6 @@ body.listening-active .landscape-prompt { flex-direction: column; gap: 15px; } - - /* Larger crossfader */ - .crossfader-section { - padding: 20px; - } - - #crossfader { - height: 50px; - } - - /* Better EQ controls */ - .eq-controls, - .filter-controls { - gap: 15px; - } - - .eq-knob, - .filter-knob { - min-width: 80px; - } - - /* Larger speed control */ - .speed-control { - padding: 15px; - } - - .speed-slider { - height: 50px; - } - - /* Better pitch bend buttons */ - .pitch-bend-controls button { - min-width: 60px; - min-height: 50px; - font-size: 1.2rem; - } } @media (max-width: 480px) { @@ -3265,24 +3312,13 @@ body.listening-active .landscape-prompt { font-size: 1.2rem !important; } - /* Completely disable edge border effects */ + /* Atmosphere adjustments for landscape */ body::before { - display: none !important; - } - - body.playing-A::before { - display: none !important; - } - - body.playing-B::before { - display: none !important; - } - - body.playing-A.playing-B::before { - display: none !important; + opacity: var(--glow-opacity); } } + /* Extra compact for very small landscape screens (phones) */ @media (max-width: 768px) and (orientation: landscape) { .deck-header { @@ -3401,157 +3437,260 @@ body.listening-active .landscape-prompt { height: 100vh !important; overflow: hidden !important; background: #050510 !important; - padding: 0 !important; + padding: 44px 0 58px 0 !important; /* top bar + bottom tabs */ } - /* Restore and clean up tab system */ + /* Clean tab system */ .mobile-tabs { display: flex !important; position: fixed !important; bottom: 0 !important; left: 0 !important; right: 0 !important; - height: 65px !important; - background: #000 !important; + height: 58px !important; + background: rgba(5, 5, 16, 0.98) !important; border-top: 2px solid #222 !important; z-index: 1000 !important; justify-content: space-around !important; - padding: 5px !important; + padding: 4px 2px !important; + backdrop-filter: blur(15px) !important; } - /* Hide sections by default, show when active class is present */ + /* Hide sections by default */ .library-section, .deck, .queue-section { display: none !important; - flex: 1 !important; width: 100% !important; flex-direction: column !important; - padding: 15px !important; overflow-y: auto !important; } + /* Show active sections */ .library-section.active, - .deck.active { + .deck.active, + .queue-section.active { display: flex !important; - } - - /* Library Page Enhancements */ - .lib-mode-toggle { - display: flex !important; - gap: 10px !important; - margin-bottom: 15px !important; - } - - .lib-mode-toggle .mode-btn { flex: 1 !important; - padding: 10px !important; - background: rgba(255, 255, 255, 0.05) !important; - border: 1px solid #333 !important; - color: #888 !important; - border-radius: 8px !important; - font-family: 'Orbitron', sans-serif !important; + } + + /* ---- LIBRARY compact ---- */ + .library-section { + padding: 6px !important; + } + + .lib-header { + padding: 6px !important; + gap: 5px !important; + } + + .lib-header input { + padding: 8px !important; + font-size: 0.85rem !important; + } + + .track-row { + padding: 8px 10px !important; + margin-bottom: 4px !important; + min-height: 44px; + } + + .track-name { font-size: 0.8rem !important; - cursor: pointer !important; + max-width: 55% !important; } - .lib-mode-toggle .mode-btn.active { - background: rgba(0, 243, 255, 0.15) !important; - border-color: var(--primary-cyan) !important; - color: var(--primary-cyan) !important; - box-shadow: 0 0 10px rgba(0, 243, 255, 0.2) !important; + .load-btn { + font-size: 0.6rem !important; + padding: 4px 6px !important; + min-height: 32px; } - /* Deck Page Enhancements - Focus mode */ + /* ---- DECK compact layout ---- */ .deck { - padding-bottom: 160px !important; - /* Space for crossfader + tabs */ + padding: 8px 10px !important; + padding-bottom: 10px !important; + gap: 0 !important; + border: none !important; + box-shadow: none !important; + border-radius: 0 !important; + } + + .deck-header { + padding: 4px 8px !important; + margin-bottom: 4px !important; } .deck-title { - font-size: 1rem !important; - margin-bottom: 15px !important; + font-size: 0.9rem !important; } + .track-display { + font-size: 0.75rem !important; + max-width: 60% !important; + } + + /* Compact waveform */ .waveform-container { - height: 120px !important; - margin-bottom: 15px !important; + height: auto !important; + margin-bottom: 4px !important; + padding: 3px !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; } .waveform-canvas { - height: 100px !important; + height: 50px !important; } + .time-display { + font-size: 0.7rem !important; + margin-top: 2px !important; + } + + /* Compact disk */ .disk-container { display: flex !important; justify-content: center !important; - margin: 20px 0 !important; + margin: 6px 0 !important; } .dj-disk { - width: 180px !important; - height: 180px !important; - border-width: 6px !important; + width: 100px !important; + height: 100px !important; + border-width: 3px !important; } .disk-label { - width: 60px !important; - height: 60px !important; - font-size: 1.5rem !important; + width: 38px !important; + height: 38px !important; + font-size: 1.1rem !important; } - /* Transport buttons for focused deck */ - .transport { - display: grid !important; - grid-template-columns: 1fr 1fr !important; - gap: 12px !important; - margin-top: 15px !important; - } - - .big-btn { - min-height: 55px !important; - font-size: 0.9rem !important; - } - - /* Fixed Crossfader at bottom, above tabs */ - .mixer-section { - display: flex !important; - height: 70px !important; - padding: 10px 40px !important; - background: #050510 !important; - border-top: 1px solid #222 !important; - position: fixed !important; - bottom: 65px !important; - left: 0 !important; - right: 0 !important; - z-index: 100 !important; - backdrop-filter: blur(10px) !important; - } - - /* Hidden elements for clean feel */ + /* Hide non-essential controls */ .hot-cues, .loop-controls, .auto-loop-controls, .eq-container, .filter-knobs, .pitch-bend-buttons, + .speed-slider, canvas#viz-A, canvas#viz-B { display: none !important; } - /* Navigation button adjustments */ - .settings-btn, - .streaming-btn, - .upload-btn, - .keyboard-btn { - bottom: 150px !important; - width: 45px !important; - height: 45px !important; + /* Hide the pitch/tempo fader-group (children are all hidden) */ + .controls-grid > .fader-group:last-child { + display: none !important; } - /* Hide the library toggle button - we're using tabs now */ + /* Volume control - compact horizontal */ + .controls-grid { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 8px !important; + margin: 6px 0 !important; + padding: 6px 10px !important; + background: rgba(0, 0, 0, 0.2) !important; + border-radius: 6px !important; + } + + .fader-group { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 10px !important; + flex: 1 !important; + } + + .fader-group label { + font-size: 0.65rem !important; + margin-bottom: 0 !important; + white-space: nowrap !important; + color: var(--text-dim) !important; + letter-spacing: 1px !important; + } + + .volume-fader { + writing-mode: horizontal-tb !important; + -webkit-appearance: none !important; + appearance: auto !important; + width: 100% !important; + height: 28px !important; + flex: 1 !important; + } + + /* Transport - single row of 4 buttons */ + .transport { + display: grid !important; + grid-template-columns: repeat(4, 1fr) !important; + gap: 4px !important; + margin-top: 6px !important; + } + + .big-btn { + min-height: 40px !important; + font-size: 0.75rem !important; + padding: 8px 2px !important; + border-radius: 6px !important; + } + + /* Inline crossfader (not fixed) */ + .mixer-section { + display: none !important; + position: relative !important; + height: auto !important; + min-height: 44px !important; + padding: 8px 20px !important; + background: rgba(0, 0, 0, 0.3) !important; + border-top: 1px solid rgba(255, 255, 255, 0.1) !important; + border-bottom: none !important; + border-left: none !important; + border-right: none !important; + border-radius: 0 !important; + flex-shrink: 0 !important; + margin: 4px 0 0 0 !important; + left: auto !important; + right: auto !important; + bottom: auto !important; + transform: none !important; + max-width: 100% !important; + width: 100% !important; + z-index: auto !important; + } + + .app-container.show-deck-A .mixer-section, + .app-container.show-deck-B .mixer-section { + display: flex !important; + } + + .mixer-section::before { + font-size: 0.9rem !important; + left: 6px !important; + } + + .mixer-section::after { + font-size: 0.9rem !important; + right: 6px !important; + } + + .xfader::-webkit-slider-thumb { + width: 44px !important; + height: 28px !important; + } + + .xfader::-moz-range-thumb { + width: 44px !important; + height: 28px !important; + } + + /* Queue sections */ + .queue-section { + padding: 10px !important; + } + + /* Hide the library toggle button - tabs handle it */ .library-toggle-mob { display: none !important; } @@ -3836,28 +3975,7 @@ body.listening-active .landscape-prompt { KEYBOARD SETTINGS PANEL ========================================== */ -.keyboard-mapping-item { - display: flex; - align-items: center; - gap: 15px; - padding: 12px; - margin-bottom: 8px; - background: rgba(255, 255, 255, 0.03); - border-radius: 6px; - border-left: 3px solid transparent; - transition: all 0.2s; -} - -.keyboard-mapping-item:hover { - background: rgba(255, 255, 255, 0.06); - border-left-color: #0ff; -} - -.keyboard-mapping-item.listening { - background: rgba(255, 255, 0, 0.1); - border-left-color: #ff0; - animation: pulse 1s infinite; -} +/* keyboard-mapping-item: grid layout defined earlier in settings panel section */ .key-display { font-family: 'Orbitron', monospace; @@ -4187,20 +4305,7 @@ body.listening-active .landscape-prompt { RESPONSIVE CROSSFADER WIDTH ========================================== */ -/* Portrait mode - narrower crossfader */ -@media (max-width: 1024px) and (orientation: portrait) { - .mixer-section { - padding: 10px 20px !important; - max-width: 70% !important; - margin: 0 auto !important; - left: 50% !important; - transform: translateX(-50%); - } - - .xfader { - width: 100% !important; - } -} +/* Portrait mode - inline crossfader (handled in portrait section above) */ /* Landscape mode - wider crossfader */ @media (max-width: 1024px) and (orientation: landscape) { @@ -4254,35 +4359,32 @@ body.listening-active .landscape-prompt { } } -/* Extra small screens - stack buttons vertically on right side */ +/* Extra small screens - compact adjustments */ @media (max-width: 480px) { - .keyboard-btn, - .streaming-btn, - .upload-btn, - .settings-btn { - right: 10px !important; - width: 45px !important; - height: 45px !important; - font-size: 0.8rem !important; + .track-name { + font-size: 0.75rem !important; + max-width: 50% !important; } - .keyboard-btn { - bottom: 250px !important; + .load-btn { + font-size: 0.55rem !important; + padding: 3px 5px !important; } - .streaming-btn { - bottom: 195px !important; + .fab-main { + width: 50px !important; + height: 50px !important; + font-size: 1.2rem !important; } - .upload-btn { - bottom: 140px !important; - } - - .settings-btn { - bottom: 85px !important; + .fab-item { + width: 42px !important; + height: 42px !important; + font-size: 0.6rem !important; } } + /* ========================================== */ /* INTEGRATED DECK QUEUE & LOOP TRACK */ /* ========================================== */ @@ -4299,8 +4401,13 @@ body.listening-active .landscape-prompt { transition: all 0.3s ease; } -#deck-A .deck-queue { border-color: rgba(0, 243, 255, 0.3); } -#deck-B .deck-queue { border-color: rgba(188, 19, 254, 0.3); } +#deck-A .deck-queue { + border-color: rgba(0, 243, 255, 0.3); +} + +#deck-B .deck-queue { + border-color: rgba(188, 19, 254, 0.3); +} .queue-header { background: rgba(255, 255, 255, 0.05); @@ -4375,17 +4482,15 @@ body.listening-active .landscape-prompt { font-size: 0.75rem; } -.deck-queue .queue-load-btn, +.deck-queue .queue-load-btn, .deck-queue .queue-remove-btn { padding: 2px 5px; font-size: 0.7rem; } -/* Mobile specific for integrated queue */ @media (max-width: 1024px) { .deck-queue { - max-height: none; - margin: 15px 0; + display: none !important; } } @@ -4408,3 +4513,251 @@ body.listening-active .landscape-prompt { opacity: 0.4; cursor: grabbing; } + +/* FAB CONTAINER FOR MOBILE */ +.fab-container { + display: none; + position: fixed; + bottom: 68px; + right: 12px; + z-index: 10004; +} + +@media (max-width: 1024px) { + .fab-container { + display: block; + } +} + +.fab-main { + width: 60px; + height: 60px; + background: var(--primary-cyan); + border: none; + border-radius: 50%; + color: #000; + font-size: 1.5rem; + cursor: pointer; + box-shadow: 0 4px 20px rgba(0, 243, 255, 0.5); + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + display: flex; + align-items: center; + justify-content: center; +} + +.fab-main.active { + transform: rotate(90deg); + background: #fff; +} + +.fab-menu { + position: absolute; + bottom: 70px; + right: 0; + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: none; + opacity: 0; + transform: translateY(20px) scale(0.8); + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.fab-menu.active { + pointer-events: all; + opacity: 1; + transform: translateY(0) scale(1); +} + +.fab-item { + width: 50px; + height: 50px; + background: rgba(20, 20, 30, 0.95); + border: 2px solid var(--primary-cyan); + border-radius: 50%; + color: var(--primary-cyan); + font-family: 'Orbitron', sans-serif; + font-size: 0.7rem; + font-weight: bold; + cursor: pointer; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5); + transition: all 0.2s; +} + +.fab-item:hover { + background: var(--primary-cyan); + color: #000; +} +/* Upload Progress Styles */ +.upload-progress-container { + position: fixed; + bottom: 20px; + right: 20px; + width: 300px; + background: rgba(10, 10, 20, 0.9); + border: 2px solid var(--primary-cyan); + border-radius: 12px; + padding: 15px; + z-index: 10005; + box-shadow: 0 0 30px rgba(0, 243, 255, 0.3); + display: none; + backdrop-filter: blur(10px); +} + +.upload-progress-container.active { + display: block; + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { transform: translateY(100px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.upload-progress-container h3 { + margin: 0 0 10px 0; + font-family: 'Orbitron', sans-serif; + font-size: 0.9rem; + color: var(--primary-cyan); + letter-spacing: 1px; +} + +.upload-progress-row { + margin-bottom: 10px; +} + +.upload-progress-row span { + display: block; + font-size: 0.75rem; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.progress-bar-wrap { + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} + +.progress-bar-inner { + height: 100%; + background: var(--primary-cyan); + box-shadow: 0 0 10px var(--primary-cyan); + transition: width 0.1s linear; +} + +/* Modal / Folder Picker Styles */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: none; + justify-content: center; + align-items: center; + z-index: 10010; + backdrop-filter: blur(5px); +} + +.modal-overlay.active { + display: flex; +} + +.modal-card { + background: #151525; + border: 2px solid var(--secondary-magenta); + border-radius: 16px; + width: 90%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 0 50px rgba(188, 19, 254, 0.3); +} + +.modal-header { + padding: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header span { + font-family: 'Orbitron', sans-serif; + color: var(--secondary-magenta); + letter-spacing: 2px; +} + +.modal-header button { + background: none; + border: none; + color: #888; + font-size: 1.5rem; + cursor: pointer; +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.path-nav { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.path-nav input { + flex: 1; + background: rgba(0, 0, 0, 0.3); + border: 1px solid #444; + color: #eee; + padding: 8px; + border-radius: 4px; + font-size: 0.8rem; +} + +.dir-list { + display: flex; + flex-direction: column; + gap: 5px; +} + +.dir-entry { + padding: 10px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + cursor: pointer; + transition: 0.2s; +} + +.dir-entry:hover { + background: rgba(188, 19, 254, 0.15); + color: var(--secondary-magenta); +} + +.modal-footer { + padding: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + text-align: right; +} + +.folder-btn { + background: rgba(188, 19, 254, 0.1); + border: 1px solid var(--secondary-magenta); + color: var(--secondary-magenta); + border-radius: 4px; + padding: 8px 12px; + cursor: pointer; + font-size: 1.2rem; + transition: all 0.3s; +} + +.folder-btn:hover { + background: rgba(188, 19, 254, 0.2); + box-shadow: 0 0 10px rgba(188, 19, 254, 0.4); +} diff --git a/techdj_qt.py b/techdj_qt.py index 828a3cf..8a227fc 100644 --- a/techdj_qt.py +++ b/techdj_qt.py @@ -1,431 +1,114 @@ #!/usr/bin/env python3 -""" -TechDJ - PyQt5 Native DJ Application -Pixel-perfect replica of the web DJ panel with neon aesthetic -""" - import sys import os import json -import requests -import numpy as np -import sounddevice as sd -import soundfile as sf -from pathlib import Path -from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QPushButton, QLabel, QSlider, QListWidget, QListWidgetItem, - QLineEdit, QFrame, QSplitter, QProgressBar, QMessageBox, - QDialog, QGridLayout, QCheckBox, QComboBox, QFileDialog) -from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, QRectF, QPropertyAnimation, QEasingCurve, QProcess, QSize -import re -from PyQt5.QtGui import (QPainter, QColor, QPen, QFont, QLinearGradient, - QRadialGradient, QBrush, QPainterPath, QFontDatabase, QIcon) -import socketio -import queue -import subprocess +import random +import math import time -import threading -from scipy import signal +import shutil +import requests +import re +import socketio +import subprocess +from pathlib import Path +import soundfile as sf +# --- BACKEND OVERRIDE --- +# On Linux, GStreamer (default) often miscalculates MP3 duration for VBR files. +# FFmpeg backend is much more reliable if available. +os.environ["QT_MULTIMEDIA_BACKEND"] = "ffmpeg" -# Color constants matching web panel -BG_DARK = QColor(10, 10, 18) -PANEL_BG = QColor(20, 20, 30, 204) # 0.8 alpha -PRIMARY_CYAN = QColor(0, 243, 255) -SECONDARY_MAGENTA = QColor(188, 19, 254) -TEXT_MAIN = QColor(224, 224, 224) -TEXT_DIM = QColor(136, 136, 136) +# --- DEPENDENCY CHECK --- +try: + import yt_dlp + HAS_YTDLP = True +except ImportError: + HAS_YTDLP = False + print("CRITICAL: yt-dlp not found. Run 'pip install yt-dlp'") +from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QPushButton, QSlider, QLabel, + QListWidget, QGroupBox, QListWidgetItem, + QLineEdit, QGridLayout, QAbstractItemView, + QDialog, QMessageBox, QFrame, QComboBox, QProgressBar, + QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, + QCheckBox, QSpinBox, QFileDialog) +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtCore import Qt, QUrl, QTimer, QPointF, QRectF, pyqtSignal, QProcess, QThread +from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QKeySequence, QIcon, QRadialGradient, QPainterPath, QShortcut -class AudioEngine: - """Efficient local audio processing engine""" - - def __init__(self): - self.decks = { - 'A': { - 'audio_data': None, - 'sample_rate': 44100, - 'position': 0, - 'playing': False, - 'volume': 0.8, - 'speed': 1.0, - 'eq': {'low': 0, 'mid': 0, 'high': 0}, - 'filters': {'lowpass': 100, 'highpass': 0}, - 'duration': 0, - 'filename': None, - 'cues': {}, - 'loop_start': None, - 'loop_end': None, - 'loop_active': False, - 'repeat': False, - 'queue': [], - 'needs_next_track': False, - }, - 'B': { - 'audio_data': None, - 'sample_rate': 44100, - 'position': 0, - 'playing': False, - 'volume': 0.8, - 'speed': 1.0, - 'eq': {'low': 0, 'mid': 0, 'high': 0}, - 'filters': {'lowpass': 100, 'highpass': 0}, - 'duration': 0, - 'filename': None, - 'cues': {}, - 'loop_start': None, - 'loop_end': None, - 'loop_active': False, - 'repeat': False, - 'queue': [], - 'needs_next_track': False, - } - } - - self.crossfader = 0.5 - self.master_volume = 0.8 - self.stream = None - self.running = False - self.broadcast_queue = queue.Queue(maxsize=100) - self.is_broadcasting = False - self.lock = threading.Lock() - - # Filter states for each deck [deck_id][filter_name][channel] - self._filter_states = { - 'A': { - 'low': [np.zeros(2), np.zeros(2)], - 'mid': [np.zeros(2), np.zeros(2)], - 'high': [np.zeros(2), np.zeros(2)], - 'lp': [np.zeros(2), np.zeros(2)], - 'hp': [np.zeros(2), np.zeros(2)] - }, - 'B': { - 'low': [np.zeros(2), np.zeros(2)], - 'mid': [np.zeros(2), np.zeros(2)], - 'high': [np.zeros(2), np.zeros(2)], - 'lp': [np.zeros(2), np.zeros(2)], - 'hp': [np.zeros(2), np.zeros(2)] - } - } - - # Pre-calculated filter coefficients - self._filter_coeffs = {} - self._init_filters() - - # Pre-allocate reuse buffers for the audio thread - self._target_indices = np.arange(2048, dtype=np.float32) # Matches blocksize - - def _init_filters(self): - """Pre-calculate coefficients for standard bands""" - sr = 44100 - # Use standard pass filters for initialization - self._filter_coeffs['low'] = signal.butter(1, 300 / (sr/2), 'low') - self._filter_coeffs['mid'] = signal.butter(1, [400 / (sr/2), 3500 / (sr/2)], 'bandpass') - self._filter_coeffs['high'] = signal.butter(1, 4000 / (sr/2), 'high') +# --- 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 - def _apply_processing(self, deck_id, chunk): - """Apply EQ and Filters to the audio chunk""" - sr = 44100 - deck = self.decks[deck_id] - states = self._filter_states[deck_id] - - # 1. Apply EQ (Gain-based) - # We use a simple gain filter approximation for performance - low_gain = 10**(deck['eq']['low'] / 20.0) - mid_gain = 10**(deck['eq']['mid'] / 20.0) - high_gain = 10**(deck['eq']['high'] / 20.0) - - if low_gain != 1.0 or mid_gain != 1.0 or high_gain != 1.0: - # Simple gain scaling for demo; real biquads are better but more CPU intensive in Python - # For now, let's use a simple 3-band gain model - # Re-implementing as basic biquads for "Pro" feel - for ch in range(2): - # Low Shelf - b, a = signal.butter(1, 300/(sr/2), 'lowshelf') - # Adjust b for gain: b_gain = [b[0]*G, b[1]]? No, standard biquad gain is better - # But Scipy's butter doesn't take gain. We'll use a simpler approach for now: - # Multiply signal by gain factors for the specific bands. - pass - - # Simplified "Musical" EQ: - # We'll just apply the filters and sum them with gains - # This is more robust than chaining biquads for a high-level API - pass - - # Since proper IIR chaining is complex in a Python loop, we'll implement - # a high-performance resonance filter for LP/HP which is the most audible - - try: - # Low Pass Filter - lp_val = deck['filters']['lowpass'] # 0-100 - if lp_val < 100: - freq = max(50, 20000 * (lp_val / 100.0)**2) - b, a = signal.butter(1, freq / (sr/2), 'low') - for ch in range(2): - chunk[:, ch], states['lp'][ch] = signal.lfilter(b, a, chunk[:, ch], zi=states['lp'][ch]) - - # High Pass Filter - hp_val = deck['filters']['highpass'] # 0-100 - if hp_val > 0: - freq = max(20, 15000 * (hp_val / 100.0)**2) - b, a = signal.butter(1, freq / (sr/2), 'high') - for ch in range(2): - chunk[:, ch], states['hp'][ch] = signal.lfilter(b, a, chunk[:, ch], zi=states['hp'][ch]) - except Exception as e: - # Fallback if filter design fails due to extreme values - print(f"Filter processing error: {e}") - pass - - # EQ Gain (Simple multiplier for now to ensure sliders "do something") - combined_eq_gain = (low_gain + mid_gain + high_gain) / 3.0 - return chunk * combined_eq_gain - - def start_stream(self): - if self.stream is not None: - return - self.running = True - self.stream = sd.OutputStream( - channels=2, - samplerate=44100, - blocksize=2048, - callback=self._audio_callback - ) - self.stream.start() - print("๐ŸŽต Audio stream started") - - def stop_stream(self): - self.running = False - if self.stream: - self.stream.stop() - self.stream.close() - self.stream = None - - def _audio_callback(self, outdata, frames, time_info, status): - output = np.zeros((frames, 2), dtype=np.float32) - output_samplerate = 44100 - - with self.lock: - for deck_id in ['A', 'B']: - deck = self.decks[deck_id] - - if not deck['playing'] or deck['audio_data'] is None: - continue - - # Calculate source indices via linear interpolation - rate_ratio = deck['sample_rate'] / output_samplerate - step = rate_ratio * deck['speed'] - - # Start and end in source domain - src_start = deck['position'] - num_src_samples_needed = frames * step - src_end = src_start + num_src_samples_needed - - # Bounds check - if src_start >= len(deck['audio_data']) - 1: - deck['playing'] = False - continue - - # Prepare source data - # Ensure we don't read past the end - read_end = int(np.ceil(src_end)) + 1 - if read_end > len(deck['audio_data']): - read_end = len(deck['audio_data']) - - src_chunk = deck['audio_data'][int(src_start):read_end] - - if len(src_chunk) < 2: - deck['playing'] = False - continue - - if src_chunk.ndim == 1: - src_chunk = np.column_stack((src_chunk, src_chunk)) - - # Time indices for interpolation - if len(self._target_indices) != frames: - self._target_indices = np.arange(frames, dtype=np.float32) - - x_target = self._target_indices * step - x_source = np.arange(len(src_chunk)) - - # Interp each channel - try: - resampled_l = np.interp(x_target, x_source, src_chunk[:, 0]) - resampled_r = np.interp(x_target, x_source, src_chunk[:, 1]) - chunk = np.column_stack((resampled_l, resampled_r)) - - # Apply processing (EQ and Filters) - chunk = self._apply_processing(deck_id, chunk) - - chunk = chunk * deck['volume'] - - if deck_id == 'A': - chunk = chunk * (1.0 - self.crossfader) - else: - chunk = chunk * self.crossfader - - output += chunk - - # Update position - deck['position'] += num_src_samples_needed - except Exception as e: - print(f"Audio thread error in interp: {e}") - deck['playing'] = False - continue - - # Handle looping - if deck['loop_active'] and deck['loop_start'] is not None and deck['loop_end'] is not None: - loop_start_frame = deck['loop_start'] * deck['sample_rate'] - loop_end_frame = deck['loop_end'] * deck['sample_rate'] - - if deck['position'] >= loop_end_frame: - deck['position'] = loop_start_frame + (deck['position'] - loop_end_frame) - - # Auto-stop at end - if deck['position'] >= len(deck['audio_data']): - if deck['repeat']: - # Loop current track - deck['position'] = 0 - elif len(deck['queue']) > 0: - # Mark that we need to load next track - # Can't load here (wrong thread), UI will handle it - deck['playing'] = False - deck['needs_next_track'] = True - else: - deck['playing'] = False - - output = output * self.master_volume - outdata[:] = output - - # Capture for broadcast - if self.is_broadcasting: - try: - self.broadcast_queue.put_nowait(output.tobytes()) - except queue.Full: - pass - - def load_track(self, deck_id, filepath): - try: - audio_data, sample_rate = sf.read(filepath, dtype='float32') - with self.lock: - self.decks[deck_id]['audio_data'] = audio_data - self.decks[deck_id]['sample_rate'] = sample_rate - self.decks[deck_id]['position'] = 0 - self.decks[deck_id]['duration'] = len(audio_data) / sample_rate - self.decks[deck_id]['filename'] = os.path.basename(filepath) - print(f"โœ… Loaded {os.path.basename(filepath)} to Deck {deck_id}") - return True - except Exception as e: - print(f"โŒ Error loading {filepath}: {e}") - return False - - def play(self, deck_id): - with self.lock: - if self.decks[deck_id]['audio_data'] is not None: - self.decks[deck_id]['playing'] = True - - def pause(self, deck_id): - with self.lock: - self.decks[deck_id]['playing'] = False - - def seek(self, deck_id, position_seconds): - with self.lock: - deck = self.decks[deck_id] - if deck['audio_data'] is not None: - deck['position'] = int(position_seconds * deck['sample_rate']) - - def set_volume(self, deck_id, volume): - with self.lock: - self.decks[deck_id]['volume'] = max(0.0, min(1.0, volume)) - - def set_speed(self, deck_id, speed): - with self.lock: - self.decks[deck_id]['speed'] = max(0.5, min(1.5, speed)) - - def set_crossfader(self, value): - with self.lock: - self.crossfader = max(0.0, min(1.0, value)) - - def get_position(self, deck_id): - with self.lock: - deck = self.decks[deck_id] - if deck['audio_data'] is not None: - return deck['position'] / deck['sample_rate'] - return 0.0 - - def set_cue(self, deck_id, cue_num): - position = self.get_position(deck_id) - with self.lock: - self.decks[deck_id]['cues'][cue_num] = position - - def jump_to_cue(self, deck_id, cue_num): - with self.lock: - if cue_num in self.decks[deck_id]['cues']: - position = self.decks[deck_id]['cues'][cue_num] - self.seek(deck_id, position) - - def set_eq(self, deck_id, band, value): - with self.lock: - self.decks[deck_id]['eq'][band] = value - - def set_filter(self, deck_id, filter_type, value): - with self.lock: - self.decks[deck_id]['filters'][filter_type] = value - - def set_repeat(self, deck_id, enabled): - """Toggle repeat/loop for a deck""" - with self.lock: - self.decks[deck_id]['repeat'] = enabled - - def set_loop_in(self, deck_id): - position = self.get_position(deck_id) - with self.lock: - self.decks[deck_id]['loop_start'] = position - # If we already have an end, activate loop - if self.decks[deck_id]['loop_end'] is not None: - self.decks[deck_id]['loop_active'] = True - - def set_loop_out(self, deck_id): - position = self.get_position(deck_id) - with self.lock: - self.decks[deck_id]['loop_end'] = position - # If we already have a start, activate loop - if self.decks[deck_id]['loop_start'] is not None: - self.decks[deck_id]['loop_active'] = True - - def exit_loop(self, deck_id): - with self.lock: - self.decks[deck_id]['loop_active'] = False - self.decks[deck_id]['loop_start'] = None - self.decks[deck_id]['loop_end'] = None - - def add_to_queue(self, deck_id, filepath): - """Add track to deck's queue""" - with self.lock: - self.decks[deck_id]['queue'].append(filepath) - - def remove_from_queue(self, deck_id, index): - """Remove track from queue by index""" - with self.lock: - if 0 <= index < len(self.decks[deck_id]['queue']): - self.decks[deck_id]['queue'].pop(index) - - def clear_queue(self, deck_id): - """Clear all tracks from queue""" - with self.lock: - self.decks[deck_id]['queue'].clear() - - def get_queue(self, deck_id): - """Get current queue (returns a copy)""" - with self.lock: - return list(self.decks[deck_id]['queue']) - - def pop_next_from_queue(self, deck_id): - """Get and remove next track from queue""" - with self.lock: - if len(self.decks[deck_id]['queue']) > 0: - return self.decks[deck_id]['queue'].pop(0) - return None +STYLESHEET = """ +QMainWindow { background-color: #050505; } +QGroupBox { background-color: #0a0a0a; border-radius: 6px; margin-top: 10px; font-family: "Courier New"; } +QGroupBox#Deck_A { border: 2px solid #00ffff; } +QGroupBox#Deck_A::title { color: #00ffff; font-weight: bold; subcontrol-origin: margin; left: 10px; } +QGroupBox#Deck_B { border: 2px solid #ff00ff; } +QGroupBox#Deck_B::title { color: #ff00ff; font-weight: bold; subcontrol-origin: margin; left: 10px; } +QPushButton { background-color: #000; color: #fff; border: 1px solid #444; padding: 6px; font-weight: bold; border-radius: 4px; } +QPushButton:hover { background-color: #222; border: 1px solid #fff; } +QPushButton:pressed { background-color: #444; } +QPushButton#btn_neon { font-family: "Courier New"; margin-bottom: 5px; font-size: 12px; } +QPushButton#btn_yt_go { background-color: #cc0000; border: 1px solid #ff0000; color: white; font-weight: bold; } +QPushButton#btn_yt_go:hover { background-color: #ff0000; } +QPushButton#btn_remove { background-color: #330000; color: #ff0000; border: 1px solid #550000; padding: 0px; font-size: 10px; min-width: 20px; min-height: 20px; } +QPushButton#btn_remove:hover { background-color: #ff0000; color: #fff; border-color: #ff5555; } +QPushButton#btn_loop { background-color: #1a1a1a; color: #888; border: 1px solid #333; font-size: 11px; } +QPushButton#btn_loop:hover { border-color: #ffa500; color: #ffa500; } +QPushButton#btn_loop:checked { background-color: #ffa500; color: #000; border: 1px solid #ffcc00; } +QPushButton#btn_loop_exit { color: #ff3333; border: 1px solid #550000; font-size: 11px; } +QPushButton#btn_loop_exit:hover { background-color: #330000; border-color: #ff0000; } +QPushButton[mode="0"] { color: #00ff00; border-color: #005500; } +QPushButton[mode="1"] { color: #ffa500; border-color: #553300; } +QPushButton#btn_lib_local { color: #00ffff; border-color: #008888; } +QPushButton#btn_lib_local:checked { background-color: #00ffff; color: #000; font-weight: bold; } +QPushButton#btn_lib_server { color: #ff00ff; border-color: #880088; } +QPushButton#btn_lib_server:checked { background-color: #ff00ff; color: #000; font-weight: bold; } +QPushButton[mode="2"] { color: #ff0000; border-color: #550000; } +QLineEdit { background-color: #111; color: #fff; border: 1px solid #555; padding: 6px; font-family: "Courier New"; } +QLineEdit:focus { border: 1px solid #00ff00; } +QListWidget { background-color: #000; border: 1px solid #333; color: #888; font-family: "Courier New"; } +QListWidget::item:selected { background-color: #222; color: #fff; border: 1px solid #00ff00; } +QListWidget#queue_list::item:selected { background-color: #331111; color: #ffaaaa; border: 1px solid #550000; } +QSlider::groove:horizontal { border: 1px solid #333; height: 4px; background: #222; } +QSlider::handle:horizontal { background: #fff; border: 2px solid #fff; width: 14px; height: 14px; margin: -6px 0; border-radius: 8px; } +QSlider::groove:vertical { border: 1px solid #333; width: 6px; background: #111; border-radius: 3px; } +QSlider::handle:vertical { background: #ccc; border: 1px solid #fff; height: 14px; width: 14px; margin: 0 -5px; border-radius: 4px; } +QSlider::sub-page:vertical { background: #444; border-radius: 3px; } +QSlider::add-page:vertical { background: #222; border-radius: 3px; } +QSlider[eq="vol"]::handle:vertical { background: #fff; border: 1px solid #fff; } +QSlider[eq="high"]::handle:vertical { background: #00ffff; border: 1px solid #00ffff; } +QSlider[eq="mid"]::handle:vertical { background: #00ff00; border: 1px solid #00ff00; } +QSlider[eq="low"]::handle:vertical { background: #ff0000; border: 1px solid #ff0000; } +QSlider#crossfader::groove:horizontal { + border: 1px solid #777; + height: 16px; + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #00ffff, stop:0.5 #111, stop:1 #ff00ff); + border-radius: 8px; +} +QSlider#crossfader::handle:horizontal { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #eee, stop:1 #888); + border: 2px solid #fff; + width: 32px; + height: 36px; + margin: -11px 0; + border-radius: 6px; +} +QSlider#crossfader::handle:horizontal:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #fff, stop:1 #aaa); + border-color: #00ff00; +} +""" +# --- WORKERS --- class DownloadThread(QThread): progress = pyqtSignal(int) @@ -438,18 +121,12 @@ class DownloadThread(QThread): def run(self): try: - print(f"๐Ÿ“ฅ Downloading from: {self.url}") response = requests.get(self.url, stream=True, timeout=30) - - # Check if request was successful if response.status_code != 200: - print(f"โŒ HTTP {response.status_code}: {self.url}") self.finished.emit(self.filepath, False) return total_size = int(response.headers.get('content-length', 0)) - print(f"๐Ÿ“ฆ File size: {total_size / 1024 / 1024:.2f} MB") - os.makedirs(os.path.dirname(self.filepath), exist_ok=True) downloaded = 0 @@ -459,2343 +136,2048 @@ class DownloadThread(QThread): f.write(chunk) downloaded += len(chunk) if total_size > 0: - progress = int((downloaded / total_size) * 100) - self.progress.emit(progress) + self.progress.emit(int((downloaded / total_size) * 100)) - print(f"โœ… Download complete: {os.path.basename(self.filepath)}") self.finished.emit(self.filepath, True) - except requests.exceptions.Timeout: - print(f"โŒ Download timeout: {self.url}") - self.finished.emit(self.filepath, False) - except requests.exceptions.ConnectionError as e: - print(f"โŒ Connection error: {e}") - self.finished.emit(self.filepath, False) - except Exception as e: - print(f"โŒ Download error: {type(e).__name__}: {e}") + except Exception: self.finished.emit(self.filepath, False) - -class BroadcastThread(QThread): - """Thread to handle FFmpeg encoding and streaming""" - chunk_ready = pyqtSignal(bytes) - error = pyqtSignal(str) - - def __init__(self, audio_queue, bitrate="192k"): +class LibraryScannerThread(QThread): + files_found = pyqtSignal(list) + def __init__(self, lib_path): super().__init__() - self.audio_queue = audio_queue - self.bitrate = bitrate - self.running = False - self.process = None - + self.lib_path = lib_path def run(self): - self.running = True - - # FFmpeg command to read raw f32le PCM and output MP3 chunks to stdout - # Using CBR and zerolatency tune for stability - cmd = [ - 'ffmpeg', - '-y', - '-fflags', 'nobuffer', - '-flags', 'low_delay', - '-probesize', '32', - '-analyzeduration', '0', - '-f', 'f32le', - '-ar', '44100', - '-ac', '2', - '-i', 'pipe:0', - '-codec:a', 'libmp3lame', - '-b:a', self.bitrate, - '-maxrate', self.bitrate, - '-minrate', self.bitrate, - '-bufsize', '64k', - '-tune', 'zerolatency', - '-flush_packets', '1', - '-f', 'mp3', - 'pipe:1' - ] + 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: - self.process = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0 - ) - - # Thread to read encoded chunks from stdout - def read_output(): - # Smaller buffer for more frequent updates (2KB = ~0.08s @ 192k) - buffer_size = 2048 - while self.running: - try: - data = self.process.stdout.read(buffer_size) - if data: - self.chunk_ready.emit(data) - else: - break - except Exception as e: - print(f"Broadcast output error: {e}") - break - - output_thread = threading.Thread(target=read_output, daemon=True) - output_thread.start() - - print(f"๐Ÿ“ก FFmpeg broadcast process started ({self.bitrate})") - - # Worker to feed stdin from the broadcast queue - while self.running: - try: - # Clear queue if it's way too full, but be less aggressive - # 100 chunks is ~4.6 seconds. If we hit 200, we're definitely lagging. - if self.audio_queue.qsize() > 200: - while self.audio_queue.qsize() > 50: - self.audio_queue.get_nowait() - - chunk = self.audio_queue.get(timeout=0.1) - if chunk and self.process and self.process.stdin: - self.process.stdin.write(chunk) - self.process.stdin.flush() - except queue.Empty: - continue - except Exception as e: - print(f"Broadcast input error: {e}") - break - + 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.error.emit(str(e)) - self.running = False - return + self.finished.emit([], str(e), False) - def stop(self): - self.running = False - if self.process: - self.process.terminate() +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: - self.process.wait(timeout=2) - except: - self.process.kill() - self.process = None - # Give output thread time to finish - time.sleep(0.1) - print("๐Ÿ›‘ Broadcast process stopped") + os.makedirs(dest) + print(f"[DEBUG] Created directory: {dest}") + except Exception as e: + self.error_occurred.emit(f"Could not create folder: {e}") + return - -class WaveformWidget(QWidget): - """Waveform display matching web panel style""" - - def __init__(self, deck_id, parent=None): - super().__init__(parent) - self.deck_id = deck_id - self.waveform_data = [] - self.position = 0.0 - self.duration = 1.0 - self.cues = {} - self.setFixedHeight(180) # Pro-visual height - self.setStyleSheet("background: #000; border: none;") # Removed internal border - - def set_waveform(self, audio_data, sample_rate): - if audio_data is None: - self.waveform_data = [] + # 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 - - samples = 2000 # Increased resolution - if audio_data.ndim > 1: - audio_data = np.mean(audio_data, axis=1) - - # Normalize globally for better visualization - max_val = np.max(np.abs(audio_data)) - if max_val > 0: - audio_data = audio_data / max_val - - block_size = max(1, len(audio_data) // samples) - self.waveform_data = [] - - for i in range(samples): - start = i * block_size - end = min(start + block_size, len(audio_data)) - if start < len(audio_data): - chunk = audio_data[start:end] - # Store both max and min for a more detailed mirror wave - self.waveform_data.append((np.max(chunk), np.min(chunk))) - - self.update() - - def set_position(self, position, duration): - # Only update if position changed significantly (reduces repaints) - if abs(position - self.position) > 0.1 or duration != self.duration: - self.position = position - self.duration = max(duration, 0.01) - self.update() - - def set_cues(self, cues): - self.cues = cues - self.update() - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - - # Background - painter.fillRect(self.rect(), QColor(0, 0, 0)) - - if not self.waveform_data: - return - - # Draw waveform - width = self.width() - height = self.height() - bar_width = width / len(self.waveform_data) - - wave_color = PRIMARY_CYAN if self.deck_id == 'A' else SECONDARY_MAGENTA - painter.setPen(Qt.NoPen) - - # Create semi-transparent brush for visual depth - brush_color = QColor(wave_color) - brush_color.setAlpha(180) - painter.setBrush(QBrush(brush_color)) - - for i, (peak, val) in enumerate(self.waveform_data): - x = i * bar_width - - # Use almost full height (0.95) to make it look "tall" as requested - # 'peak' and 'val' are normalized -1 to 1 - pos_height = peak * (height / 2) * 0.95 - neg_height = abs(val) * (height / 2) * 0.95 - - # Top half - painter.drawRect(int(x), int(height/2 - pos_height), max(1, int(bar_width)), int(pos_height)) - # Bottom half - painter.drawRect(int(x), int(height/2), max(1, int(bar_width)), int(neg_height)) - - # Draw cue markers - if self.duration > 0: - painter.setPen(QPen(QColor(255, 255, 255), 1)) - for cue_time in self.cues.values(): - x = (cue_time / self.duration) * width - painter.drawLine(int(x), 0, int(x), height) - - # Draw playhead - if self.duration > 0: - playhead_x = (self.position / self.duration) * width - painter.setPen(QPen(QColor(255, 255, 0), 2)) - painter.drawLine(int(playhead_x), 0, int(playhead_x), height) - - def mousePressEvent(self, event): - """Allow seeking by clicking on waveform""" - if self.duration > 0: - percent = event.x() / self.width() - seek_time = percent * self.duration - self.parent().parent().seek_deck(seek_time) - -class VinylDiskWidget(QWidget): - """Animated vinyl disk matching web panel""" - - clicked = pyqtSignal() - - def __init__(self, deck_id, parent=None): - super().__init__(parent) - self.deck_id = deck_id - self.rotation = 0 - self.playing = False - self.setFixedSize(120, 120) + self.final_filename = "" + self.error_log = "" + print(f"[DEBUG] Starting download: {url} -> {dest} (format: {audio_format})") - # Rotation animation - self.timer = QTimer() - self.timer.timeout.connect(self.rotate) + cmd = sys.executable + out_tmpl = os.path.join(dest, '%(title)s.%(ext)s') - def set_playing(self, playing): - self.playing = playing - if playing: - self.timer.start(100) # 10 FPS - reduced for better performance + # 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: - self.timer.stop() - self.update() - - def set_speed(self, speed): - self.speed = speed + # Best Quality: Download original audio (faster, better quality) + args.extend([ + "-f", "bestaudio[ext=m4a]/bestaudio", # Prefer m4a, fallback to best + ]) - def rotate(self): - # Base rotation is 5 degrees, scaled by playback speed - speed_factor = getattr(self, 'speed', 1.0) - self.rotation = (self.rotation + (5 * speed_factor)) % 360 - self.update() - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) + # Common args + args.extend([ + "-o", out_tmpl, + "--no-playlist", + "--newline", + "--no-warnings", + "--progress", + "--print", "after_move:filepath", + url + ]) - center_x = self.width() / 2 - center_y = self.height() / 2 - radius = min(center_x, center_y) - 5 + self.start(cmd, args) - # Rotate if playing - if self.playing: - painter.translate(center_x, center_y) - painter.rotate(self.rotation) - painter.translate(-center_x, -center_y) - - # Vinyl gradient - gradient = QRadialGradient(center_x, center_y, radius) - gradient.setColorAt(0, QColor(34, 34, 34)) - gradient.setColorAt(0.1, QColor(17, 17, 17)) - gradient.setColorAt(1, QColor(0, 0, 0)) - - painter.setBrush(gradient) - painter.setPen(QPen(QColor(51, 51, 51), 2)) - painter.drawEllipse(int(center_x - radius), int(center_y - radius), - int(radius * 2), int(radius * 2)) - - # Grooves - painter.setPen(QPen(QColor(24, 24, 24), 1)) - for i in range(5, int(radius), 8): - painter.drawEllipse(int(center_x - i), int(center_y - i), i * 2, i * 2) - - # Center label - label_radius = 25 - label_color = PRIMARY_CYAN if self.deck_id == 'A' else SECONDARY_MAGENTA - painter.setBrush(label_color) - painter.setPen(QPen(label_color.darker(120), 2)) - painter.drawEllipse(int(center_x - label_radius), int(center_y - label_radius), - label_radius * 2, label_radius * 2) - - # Label text - painter.setPen(QColor(0, 0, 0)) - font = QFont("Orbitron", 16, QFont.Bold) - painter.setFont(font) - painter.drawText(self.rect(), Qt.AlignCenter, self.deck_id) - - # Glow effect when playing - if self.playing: - painter.setPen(QPen(label_color, 3)) - painter.setBrush(Qt.NoBrush) - painter.drawEllipse(int(center_x - radius - 3), int(center_y - radius - 3), - int((radius + 3) * 2), int((radius + 3) * 2)) - - def mousePressEvent(self, event): - self.clicked.emit() + 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}") - -class NeonButton(QPushButton): - """Neon-styled button matching web panel""" - - def __init__(self, text, color=PRIMARY_CYAN, parent=None): - super().__init__(text, parent) - self.neon_color = color - self.is_active = False - self.update_style() - - def set_active(self, active): - self.is_active = active - self.update_style() - - def update_style(self): - if self.is_active: - self.setStyleSheet(f""" - QPushButton {{ - background: rgba({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}, 0.3); - border: 2px solid rgb({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}); - color: rgb({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}); - font-family: 'Orbitron'; - font-weight: bold; - padding: 8px; - border-radius: 4px; - }} - QPushButton:hover {{ - background: rgba({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}, 0.5); - }} - """) - else: - self.setStyleSheet(f""" - QPushButton {{ - background: #222; - border: 1px solid #444; - color: #666; - font-family: 'Orbitron'; - font-weight: bold; - padding: 8px; - border-radius: 4px; - }} - QPushButton:hover {{ - background: #333; - color: #888; - }} - """) - - -class DeckWidget(QWidget): - """Complete deck widget matching web panel layout""" - - def __init__(self, deck_id, audio_engine, parent=None): - super().__init__(parent) - self.deck_id = deck_id - self.audio_engine = audio_engine - self.color = PRIMARY_CYAN if deck_id == 'A' else SECONDARY_MAGENTA - - self.init_ui() - - # Update timer - self.timer = QTimer() - self.timer.timeout.connect(self.update_display) - self.timer.start(100) # 10 FPS - reduced for better performance - - def init_ui(self): - layout = QVBoxLayout() - layout.setSpacing(5) # Reduced from 8 - layout.setContentsMargins(10, 8, 10, 10) # Reduced top margin - - # Headers removed as requested - - # Waveform - waveform_container = QWidget() - waveform_container.setFixedHeight(184) # 180px graph + 4px padding - waveform_container.setStyleSheet("background: #111; border: 1px solid #333; border-radius: 4px;") - waveform_layout = QVBoxLayout(waveform_container) - waveform_layout.setContentsMargins(2, 2, 2, 2) - - self.waveform = WaveformWidget(self.deck_id, self) - waveform_layout.addWidget(self.waveform) - - # Subtle Metadata Overlay (Integrated into Graph Box) - meta_layout = QHBoxLayout() - meta_layout.setContentsMargins(4, 0, 4, 1) - - self.deck_id_label = QLabel(f"[{self.deck_id}]") - self.deck_id_label.setStyleSheet(f"color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); font-family: 'Orbitron'; font-size: 9px; font-weight: bold;") - meta_layout.addWidget(self.deck_id_label) - - self.track_label = QLabel("EMPTY") - self.track_label.setStyleSheet("color: #bbb; font-family: 'Rajdhani'; font-size: 9px; font-weight: bold;") - meta_layout.addWidget(self.track_label, 1) - - self.time_label = QLabel("0:00 / 0:00") - self.time_label.setStyleSheet("color: #888; font-family: 'Orbitron'; font-size: 8px;") - meta_layout.addWidget(self.time_label) - - waveform_layout.addLayout(meta_layout) - - layout.addWidget(waveform_container) - - # Restoring the nice DJ circles - disk_container = QHBoxLayout() - disk_container.addStretch() - self.vinyl_disk = VinylDiskWidget(self.deck_id) - self.vinyl_disk.clicked.connect(self.toggle_play) - disk_container.addWidget(self.vinyl_disk) - disk_container.addStretch() - layout.addLayout(disk_container) - - # Hot Cues - cue_layout = QGridLayout() - cue_layout.setSpacing(3) - self.cue_buttons = [] - for i in range(4): - btn = NeonButton(f"CUE {i+1}", self.color) - btn.clicked.connect(lambda checked, num=i+1: self.handle_cue(num)) - cue_layout.addWidget(btn, 0, i) - self.cue_buttons.append(btn) - layout.addLayout(cue_layout) - - # Loop Controls - loop_layout = QGridLayout() - loop_layout.setSpacing(3) - loop_in = NeonButton("LOOP IN", QColor(255, 102, 0)) - loop_in.clicked.connect(lambda: self.audio_engine.set_loop_in(self.deck_id)) - - loop_out = NeonButton("LOOP OUT", QColor(255, 102, 0)) - loop_out.clicked.connect(lambda: self.audio_engine.set_loop_out(self.deck_id)) - - loop_exit = NeonButton("EXIT", QColor(255, 102, 0)) - loop_exit.clicked.connect(lambda: self.audio_engine.exit_loop(self.deck_id)) - - loop_layout.addWidget(loop_in, 0, 0) - loop_layout.addWidget(loop_out, 0, 1) - loop_layout.addWidget(loop_exit, 0, 2) - layout.addLayout(loop_layout) - - # Controls Grid - controls = QGridLayout() - controls.setSpacing(8) - - # Volume - vol_label = QLabel("VOLUME") - vol_label.setStyleSheet("color: #888; font-size: 10px;") - controls.addWidget(vol_label, 0, 0) - self.volume_slider = QSlider(Qt.Horizontal) - self.volume_slider.setRange(0, 100) - self.volume_slider.setValue(80) - self.volume_slider.valueChanged.connect(self.on_volume_change) - self.volume_slider.setStyleSheet(self.get_slider_style()) - controls.addWidget(self.volume_slider, 1, 0) - - # EQ - eq_widget = QWidget() - eq_layout = QHBoxLayout(eq_widget) - eq_layout.setSpacing(8) - self.eq_sliders = {} - - for band in ['HIGH', 'MID', 'LOW']: - band_widget = QWidget() - band_layout = QVBoxLayout(band_widget) - band_layout.setSpacing(2) - band_layout.setContentsMargins(0, 0, 0, 0) + 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" - slider = QSlider(Qt.Vertical) - slider.setRange(-20, 20) - slider.setValue(0) - slider.setFixedHeight(80) - slider.setStyleSheet(self.get_slider_style()) - slider.valueChanged.connect(lambda v, b=band.lower(): self.on_eq_change(b, v)) - self.eq_sliders[band.lower()] = slider - - label = QLabel(band) - label.setStyleSheet("color: #888; font-size: 9px;") - label.setAlignment(Qt.AlignCenter) - - band_layout.addWidget(slider) - band_layout.addWidget(label) - eq_layout.addWidget(band_widget) - - controls.addWidget(eq_widget, 0, 1, 2, 1) - - # Filters - filter_widget = QWidget() - filter_layout = QVBoxLayout(filter_widget) - filter_layout.setSpacing(4) - - lp_label = QLabel("LOW-PASS") - lp_label.setStyleSheet("color: #888; font-size: 9px;") - filter_layout.addWidget(lp_label) - self.lp_slider = QSlider(Qt.Horizontal) - self.lp_slider.setRange(0, 100) - self.lp_slider.setValue(100) - self.lp_slider.setStyleSheet(self.get_slider_style()) - self.lp_slider.valueChanged.connect(lambda v: self.audio_engine.set_filter(self.deck_id, 'lowpass', v)) - filter_layout.addWidget(self.lp_slider) - - hp_label = QLabel("HIGH-PASS") - hp_label.setStyleSheet("color: #888; font-size: 9px;") - filter_layout.addWidget(hp_label) - self.hp_slider = QSlider(Qt.Horizontal) - self.hp_slider.setRange(0, 100) - self.hp_slider.setValue(0) - self.hp_slider.setStyleSheet(self.get_slider_style()) - self.hp_slider.valueChanged.connect(lambda v: self.audio_engine.set_filter(self.deck_id, 'highpass', v)) - filter_layout.addWidget(self.hp_slider) - - controls.addWidget(filter_widget, 0, 2, 2, 1) - - # Speed - speed_widget = QWidget() - speed_layout = QVBoxLayout(speed_widget) - speed_layout.setSpacing(4) - - speed_label = QLabel("PITCH / TEMPO") - speed_label.setStyleSheet("color: #888; font-size: 9px;") - speed_layout.addWidget(speed_label) - - self.speed_slider = QSlider(Qt.Horizontal) - self.speed_slider.setRange(50, 150) - self.speed_slider.setValue(100) - self.speed_slider.valueChanged.connect(self.on_speed_change) - self.speed_slider.setStyleSheet(self.get_slider_style()) - speed_layout.addWidget(self.speed_slider) - - bend_layout = QHBoxLayout() - bend_minus = QPushButton("-") - bend_minus.setFixedSize(30, 25) - bend_minus.pressed.connect(lambda: self.on_pitch_bend(-0.02)) - bend_minus.released.connect(lambda: self.on_pitch_bend(0)) - - bend_plus = QPushButton("+") - bend_plus.setFixedSize(30, 25) - bend_plus.pressed.connect(lambda: self.on_pitch_bend(0.02)) - bend_plus.released.connect(lambda: self.on_pitch_bend(0)) - - bend_layout.addWidget(bend_minus) - bend_layout.addWidget(bend_plus) - speed_layout.addLayout(bend_layout) - - controls.addWidget(speed_widget, 0, 3, 2, 1) - - layout.addLayout(controls) - - # Transport - transport = QHBoxLayout() - transport.setSpacing(4) - - self.play_btn = NeonButton("โ–ถ PLAY", self.color) - self.play_btn.clicked.connect(self.play) - transport.addWidget(self.play_btn) - - self.pause_btn = NeonButton("โธ PAUSE") - self.pause_btn.clicked.connect(self.pause) - transport.addWidget(self.pause_btn) - - sync_btn = NeonButton("SYNC", self.color) - sync_btn.clicked.connect(self.on_sync) - transport.addWidget(sync_btn) - - reset_btn = NeonButton("๐Ÿ”„ RESET") - reset_btn.clicked.connect(self.reset_deck) - transport.addWidget(reset_btn) - - self.loop_btn = NeonButton("๐Ÿ” LOOP") - self.loop_btn.setCheckable(True) - self.loop_btn.clicked.connect(self.toggle_loop) - transport.addWidget(self.loop_btn) - - layout.addLayout(transport) - - # Queue List - queue_container = QWidget() - queue_container.setStyleSheet("background: rgba(0, 0, 0, 0.4); border-top: 1px solid #333;") - queue_layout = QVBoxLayout(queue_container) - queue_layout.setContentsMargins(5, 5, 5, 5) - queue_layout.setSpacing(2) - - queue_label = QLabel("NEXT UP / QUEUE") - queue_label.setStyleSheet(f"color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); font-family: 'Orbitron'; font-size: 9px; font-weight: bold;") - queue_layout.addWidget(queue_label) - - self.queue_list = QListWidget() - self.queue_list.setFixedHeight(80) - self.queue_list.setStyleSheet(""" - QListWidget { - background: transparent; - border: none; - color: #aaa; - font-family: 'Rajdhani'; - font-size: 10px; - } - QListWidget::item { - padding: 2px; - border-bottom: 1px solid #222; - } - """) - queue_layout.addWidget(self.queue_list) - layout.addWidget(queue_container) - - layout.addStretch() # Push everything up - - self.setLayout(layout) - - # Deck styling - self.setStyleSheet(f""" - QWidget {{ - background: rgba(20, 20, 30, 0.8); - color: #e0e0e0; - font-family: 'Rajdhani'; - }} - QWidget#deck {{ - border: 2px solid rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); - border-radius: 8px; - }} - """) - self.setObjectName("deck") - - def get_slider_style(self): - return """ - QSlider::groove:horizontal { - height: 8px; - background: #333; - border-radius: 4px; - } - QSlider::handle:horizontal { - background: #ccc; - border: 2px solid #888; - width: 16px; - margin: -4px 0; - border-radius: 8px; - } - QSlider::groove:vertical { - width: 8px; - background: #333; - border-radius: 4px; - } - QSlider::handle:vertical { - background: #ccc; - border: 2px solid #888; - height: 16px; - margin: 0 -4px; - border-radius: 8px; - } - """ - - def load_track(self, filepath): - if self.audio_engine.load_track(self.deck_id, filepath): - filename = os.path.basename(filepath) - self.track_label.setText(filename.upper()) - deck = self.audio_engine.decks[self.deck_id] - self.waveform.set_waveform(deck['audio_data'], deck['sample_rate']) - - def play(self): - self.audio_engine.play(self.deck_id) - self.vinyl_disk.set_playing(True) - self.play_btn.set_active(True) - - def pause(self): - self.audio_engine.pause(self.deck_id) - self.vinyl_disk.set_playing(False) - self.play_btn.set_active(False) - - def toggle_play(self): - if self.audio_engine.decks[self.deck_id]['playing']: - self.pause() - else: - self.play() - - def on_volume_change(self, value): - self.audio_engine.set_volume(self.deck_id, value / 100.0) - - def on_speed_change(self, value): - self.audio_engine.set_speed(self.deck_id, value / 100.0) - - def on_eq_change(self, band, value): - self.audio_engine.set_eq(self.deck_id, band, value) - - def on_sync(self): - """Match speed to other deck""" - other_deck_id = 'B' if self.deck_id == 'A' else 'A' - other_speed = self.audio_engine.decks[other_deck_id]['speed'] - self.speed_slider.setValue(int(other_speed * 100)) - print(f"๐ŸŽต Deck {self.deck_id} synced to {other_speed:.2f}x") - - def on_pitch_bend(self, amount): - """Temporarily adjust speed for nudging""" - base_speed = self.speed_slider.value() / 100.0 - self.audio_engine.set_speed(self.deck_id, base_speed + amount) - - def handle_cue(self, cue_num): - deck = self.audio_engine.decks[self.deck_id] - if cue_num in deck['cues']: - self.audio_engine.jump_to_cue(self.deck_id, cue_num) - else: - self.audio_engine.set_cue(self.deck_id, cue_num) - self.cue_buttons[cue_num-1].set_active(True) - - def seek_deck(self, time): - self.audio_engine.seek(self.deck_id, time) - - def reset_deck(self): - """Reset all deck controls to default values""" - # Setting values on sliders will trigger the valueChanged signal - # which will in turn update the audio engine. - - # Reset volume to 80% - self.volume_slider.setValue(80) - - # Reset speed to 100% - self.speed_slider.setValue(100) - - # Reset EQ sliders to 0 - if hasattr(self, 'eq_sliders'): - for band, slider in self.eq_sliders.items(): - slider.setValue(0) - - # Reset filter sliders - self.lp_slider.setValue(100) - self.hp_slider.setValue(0) - - print(f"๐Ÿ”„ Deck {self.deck_id} reset to defaults") - - def toggle_loop(self): - """Toggle loop/repeat for this deck""" - is_looping = self.loop_btn.isChecked() - self.audio_engine.set_repeat(self.deck_id, is_looping) - - if is_looping: - self.loop_btn.setStyleSheet(f""" - QPushButton {{ - background: rgba({self.color.red()}, {self.color.green()}, {self.color.blue()}, 0.3); - border: 2px solid rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); - color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); - font-family: 'Orbitron'; - font-size: 12px; - font-weight: bold; - border-radius: 6px; - }} - """) - print(f"๐Ÿ” Deck {self.deck_id} loop enabled") - else: - self.loop_btn.setStyleSheet(""" - QPushButton { - background: rgba(0, 0, 0, 0.3); - border: 2px solid #666; - color: #888; - font-family: 'Orbitron'; - font-size: 12px; - border-radius: 6px; - } - """) - print(f"โน๏ธ Deck {self.deck_id} loop disabled") - - def update_display(self): - deck = self.audio_engine.decks[self.deck_id] - position = self.audio_engine.get_position(self.deck_id) - duration = deck['duration'] - - # Check if we need to load next track from queue - if deck.get('needs_next_track', False): - deck['needs_next_track'] = False - next_track = self.audio_engine.pop_next_from_queue(self.deck_id) - if next_track: - print(f"๐Ÿ“‹ Auto-loading next track from queue: {os.path.basename(next_track)}") - self.load_track(next_track) - self.play() - - # Time calculations - pos_min = int(position // 60) - pos_sec = int(position % 60) - dur_min = int(duration // 60) - dur_sec = int(duration % 60) - self.time_label.setText(f"{pos_min}:{pos_sec:02d} / {dur_min}:{dur_sec:02d}") - - self.waveform.set_position(position, duration) - self.waveform.set_cues(deck['cues']) - self.vinyl_disk.set_speed(deck['speed']) - - # Update Queue Display (only when changed) - current_queue = deck.get('queue', []) - # Check if queue actually changed (not just count) - queue_changed = False - if self.queue_list.count() != len(current_queue): - queue_changed = True - else: - # Check if items are different - for i, track_path in enumerate(current_queue): - if i >= self.queue_list.count() or self.queue_list.item(i).text() != os.path.basename(track_path): - queue_changed = True + def handle_finished(self): + if self.exitCode() == 0 and self.final_filename: + # Poll for the file to fully appear on disk (replaces the unreliable 0.5s sleep). + # yt-dlp moves the file after FFmpeg post-processing finishes, so the file + # may take a moment to be visible. We wait up to 10 seconds. + deadline = time.time() + 10.0 + while time.time() < deadline: + if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0: break + time.sleep(0.1) + + if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0: + print(f"[DEBUG] Download complete: {self.final_filename} ({os.path.getsize(self.final_filename)} bytes)") + self.download_finished.emit(self.final_filename) + else: + self.error_occurred.emit(f"Download finished but file missing or empty:\n{self.final_filename}") + elif self.exitCode() == 0 and not self.final_filename: + self.error_occurred.emit("Download finished but could not determine output filename.\nCheck the download folder manually.") + else: + self.error_occurred.emit(f"Download process failed.\n{self.error_log}") + +class SettingsDialog(QDialog): + def __init__(self, settings_data, parent=None): + super().__init__(parent) + self.setWindowTitle("Settings") + self.resize(650, 650) + self.setStyleSheet("background-color: #111; color: #fff;") - if queue_changed: - self.queue_list.clear() - for track_path in current_queue: - filename = os.path.basename(track_path) - self.queue_list.addItem(filename) + # Store all settings + self.shortcuts = settings_data.get("shortcuts", {}).copy() + self.audio_settings = settings_data.get("audio", {}).copy() + self.ui_settings = settings_data.get("ui", {}).copy() + self.library_settings = settings_data.get("library", {}).copy() + + layout = QVBoxLayout(self) + + # Create tab widget + self.tabs = QTabWidget() + self.tabs.setStyleSheet(""" + QTabWidget::pane { border: 1px solid #333; background: #0a0a0a; } + QTabBar::tab { background: #222; color: #888; padding: 8px 16px; margin: 2px; } + QTabBar::tab:selected { background: #00ffff; color: #000; font-weight: bold; } + QTabBar::tab:hover { background: #333; color: #fff; } + """) + + # Tab 1: Keyboard Shortcuts + self.shortcuts_tab = self.create_shortcuts_tab() + self.tabs.addTab(self.shortcuts_tab, "Keyboard") + + # Tab 2: Audio & Recording + self.audio_tab = self.create_audio_tab() + self.tabs.addTab(self.audio_tab, "Audio") + + # Tab 3: UI Preferences + self.ui_tab = self.create_ui_tab() + self.tabs.addTab(self.ui_tab, "UI") + + # Tab 4: Library + self.library_tab = self.create_library_tab() + self.tabs.addTab(self.library_tab, "Library") + + layout.addWidget(self.tabs) + + # Buttons + btn_layout = QHBoxLayout() + self.save_btn = QPushButton("Save All") + self.save_btn.clicked.connect(self.accept) + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.save_btn) + btn_layout.addWidget(self.cancel_btn) + layout.addLayout(btn_layout) + + def create_shortcuts_tab(self): + widget = QWidget() + layout = QVBoxLayout(widget) + + self.shortcuts_table = QTableWidget(len(self.shortcuts), 2) + self.shortcuts_table.setHorizontalHeaderLabels(["Action", "Key"]) + self.shortcuts_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.shortcuts_table.setStyleSheet("background-color: #000; border: 1px solid #333;") + self.shortcuts_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.shortcuts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.shortcuts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + + actions = sorted(self.shortcuts.keys()) + for row, action in enumerate(actions): + self.shortcuts_table.setItem(row, 0, QTableWidgetItem(action)) + self.shortcuts_table.setItem(row, 1, QTableWidgetItem(self.shortcuts[action])) + + layout.addWidget(self.shortcuts_table) + + rebind_btn = QPushButton("Rebind Selected Shortcut") + rebind_btn.clicked.connect(self.rebind_selected) + layout.addWidget(rebind_btn) + + return widget + + def create_audio_tab(self): + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Streaming section + stream_group = QLabel("Live Streaming") + stream_group.setStyleSheet("font-size: 14px; font-weight: bold; color: #00ffff; margin-top: 10px;") + layout.addWidget(stream_group) + + stream_url_label = QLabel("Stream Server URL:") + self.stream_url_input = QLineEdit() + self.stream_url_input.setPlaceholderText("http://YOUR_SERVER_IP:8080/api/stream") + current_stream_url = self.audio_settings.get("stream_server_url", "http://localhost:8080/api/stream") + self.stream_url_input.setText(current_stream_url) + self.stream_url_input.setStyleSheet(""" + QLineEdit { + background: #1a1a1a; + border: 1px solid #333; + padding: 8px; + color: #fff; + border-radius: 4px; + } + """) + + layout.addWidget(stream_url_label) + layout.addWidget(self.stream_url_input) + layout.addSpacing(20) + + # Recording section + rec_group = QLabel("Recording") + rec_group.setStyleSheet("font-size: 14px; font-weight: bold; color: #ff00ff; margin-top: 10px;") + layout.addWidget(rec_group) + + # Sample rate + rate_label = QLabel("Recording Sample Rate:") + self.sample_rate_combo = QComboBox() + self.sample_rate_combo.addItem("44.1 kHz", 44100) + self.sample_rate_combo.addItem("48 kHz (Recommended)", 48000) + current_rate = self.audio_settings.get("recording_sample_rate", 48000) + self.sample_rate_combo.setCurrentIndex(0 if current_rate == 44100 else 1) + + # Format + format_label = QLabel("Recording Format:") + self.format_combo = QComboBox() + self.format_combo.addItem("WAV (Lossless)", "wav") + self.format_combo.addItem("MP3 (Compressed)", "mp3") + current_format = self.audio_settings.get("recording_format", "wav") + self.format_combo.setCurrentIndex(0 if current_format == "wav" else 1) + + layout.addWidget(rate_label) + layout.addWidget(self.sample_rate_combo) + layout.addSpacing(10) + layout.addWidget(format_label) + layout.addWidget(self.format_combo) + layout.addStretch() + + return widget + + def create_ui_tab(self): + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Neon mode default + neon_label = QLabel("Default Neon Edge Mode:") + self.neon_combo = QComboBox() + self.neon_combo.addItem("Off", 0) + self.neon_combo.addItem("Blue (Cyan)", 1) + self.neon_combo.addItem("Purple (Magenta)", 2) + current_neon = self.ui_settings.get("neon_mode", 0) + self.neon_combo.setCurrentIndex(current_neon) + + layout.addWidget(neon_label) + layout.addWidget(self.neon_combo) + layout.addStretch() + + return widget + + def create_library_tab(self): + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Auto-scan + self.auto_scan_check = QCheckBox("Auto-scan library on startup") + self.auto_scan_check.setChecked(self.library_settings.get("auto_scan", True)) + + # YouTube default format + yt_label = QLabel("YouTube Download Default Format:") + self.yt_format_combo = QComboBox() + self.yt_format_combo.addItem("MP3 (Universal)", "mp3") + self.yt_format_combo.addItem("Best Quality (Faster)", "best") + current_yt = self.library_settings.get("yt_default_format", "mp3") + self.yt_format_combo.setCurrentIndex(0 if current_yt == "mp3" else 1) + + layout.addWidget(self.auto_scan_check) + layout.addSpacing(10) + layout.addWidget(yt_label) + layout.addWidget(self.yt_format_combo) + layout.addStretch() + + return widget + + def rebind_selected(self): + row = self.shortcuts_table.currentRow() + if row < 0: + QMessageBox.warning(self, "No Selection", "Please select an action to rebind.") + return + + action = self.shortcuts_table.item(row, 0).text() + + from PyQt6.QtWidgets import QInputDialog + new_key, ok = QInputDialog.getText(self, "Rebind Key", f"Enter new key sequence for {action}:", text=self.shortcuts[action]) + if ok and new_key: + self.shortcuts[action] = new_key + self.shortcuts_table.item(row, 1).setText(new_key) + + def get_all_settings(self): + """Return all settings as a dictionary""" + return { + "shortcuts": self.shortcuts, + "audio": { + "recording_sample_rate": self.sample_rate_combo.currentData(), + "recording_format": self.format_combo.currentData(), + "stream_server_url": self.stream_url_input.text(), + }, + "ui": { + "neon_mode": self.neon_combo.currentData(), + }, + "library": { + "auto_scan": self.auto_scan_check.isChecked(), + "yt_default_format": self.yt_format_combo.currentData(), + } + } -class YouTubeSearchDialog(QDialog): - """Dialog to display and select YouTube search results""" - item_selected = pyqtSignal(str) # Emits the URL +class YTResultDialog(QDialog): def __init__(self, results, parent=None): super().__init__(parent) - self.setWindowTitle("YouTube Search Results") - self.setFixedWidth(600) - self.setFixedHeight(400) - self.setStyleSheet(f""" - QDialog {{ - background: rgb({BG_DARK.red()}, {BG_DARK.green()}, {BG_DARK.blue()}); - border: 2px solid #444; - }} - QLabel {{ color: white; font-family: 'Rajdhani'; }} + 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) - layout.setContentsMargins(15, 15, 15, 15) - - header = QLabel("SELECT A VERSION TO DOWNLOAD") - header.setStyleSheet("font-family: 'Orbitron'; font-weight: bold; font-size: 14px; color: #00f3ff; margin-bottom: 10px;") + header = QLabel("YouTube Search Results") layout.addWidget(header) self.list_widget = QListWidget() - self.list_widget.setStyleSheet(""" - QListWidget { - background: rgba(0, 0, 0, 0.4); - border: 1px solid #333; - border-radius: 4px; - color: #ddd; - padding: 5px; - } - QListWidget::item { - border-bottom: 1px solid #222; - padding: 8px; - } - QListWidget::item:hover { - background: rgba(0, 243, 255, 0.1); - } - """) layout.addWidget(self.list_widget) - for res in results: - # Title ||| Duration ||| URL - parts = res.split(" ||| ") - if len(parts) < 3: continue + 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') - title, duration, url = parts[0], parts[1], parts[2] + item = QListWidgetItem(f"{title_text}\n [{m:02}:{s:02}] - {channel}") + item.setData(Qt.ItemDataRole.UserRole, vid.get('url')) + self.list_widget.addItem(item) - item = QListWidgetItem(self.list_widget) - item.setSizeHint(QSize(0, 50)) - - widget = QWidget() - item_layout = QHBoxLayout(widget) - item_layout.setContentsMargins(5, 0, 5, 0) - - info_vbox = QVBoxLayout() - info_vbox.setSpacing(0) - - title_label = QLabel(title) - title_label.setStyleSheet("font-weight: bold; font-size: 12px; color: #eee;") - title_label.setWordWrap(True) - info_vbox.addWidget(title_label) - - dur_label = QLabel(f"Duration: {duration}") - dur_label.setStyleSheet("font-size: 10px; color: #888;") - info_vbox.addWidget(dur_label) - - item_layout.addLayout(info_vbox, 1) - - dl_btn = NeonButton("DOWNLOAD", PRIMARY_CYAN) - dl_btn.setFixedSize(90, 26) - dl_btn.clicked.connect(lambda _, u=url: self.on_dl_click(u)) - item_layout.addWidget(dl_btn) - - self.list_widget.setItemWidget(item, widget) - - def on_dl_click(self, url): - self.item_selected.emit(url) - self.accept() + 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 TechDJMainWindow(QMainWindow): - """Main window matching web panel layout""" +class RecordingWorker(QProcess): + """Records system audio output using FFmpeg""" + recording_started = pyqtSignal() + recording_error = pyqtSignal(str) + def __init__(self, parent=None): + super().__init__(parent) + self.output_file = "" + self.readyReadStandardError.connect(self.handle_error) + + def start_recording(self, output_path): + """Start recording system audio to file""" + self.output_file = output_path + + # Check if FFmpeg is available + if not shutil.which("ffmpeg"): + self.recording_error.emit("FFmpeg not found. Install with: sudo apt install ffmpeg") + return False + + print(f"[RECORDING] Starting: {output_path}") + + # FFmpeg command to record PulseAudio output with high quality + # IMPORTANT: Use .monitor to capture OUTPUT (what you hear), not INPUT (microphone) + # -f pulse: use PulseAudio + # -i default.monitor: capture system audio OUTPUT (not microphone) + # -ac 2: stereo + # -ar 48000: 48kHz sample rate (higher quality than 44.1kHz) + # -acodec pcm_s16le: uncompressed 16-bit PCM (lossless) + # -sample_fmt s16: 16-bit samples + args = [ + "-f", "pulse", + "-i", "default.monitor", # .monitor captures OUTPUT, not microphone! + "-ac", "2", + "-ar", "48000", # Higher sample rate for better quality + "-acodec", "pcm_s16le", # Lossless PCM codec + "-sample_fmt", "s16", + "-y", # Overwrite if exists + output_path + ] + + self.start("ffmpeg", args) + self.recording_started.emit() + return True + + def stop_recording(self): + """Stop the recording""" + if self.state() == QProcess.ProcessState.Running: + print("[RECORDING] Stopping...") + # Send 'q' to FFmpeg to gracefully stop + self.write(b"q") + self.waitForFinished(3000) + if self.state() == QProcess.ProcessState.Running: + self.kill() + + def handle_error(self): + """Handle FFmpeg stderr (which includes progress info)""" + err = self.readAllStandardError().data().decode('utf-8', errors='ignore').strip() + if err and "error" in err.lower(): + print(f"[RECORDING ERROR] {err}") + +class StreamingWorker(QThread): + """Streams system audio output to a server using Socket.IO Chunks""" + streaming_started = pyqtSignal() + streaming_error = pyqtSignal(str) + listener_count = pyqtSignal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.sio = socketio.Client() + self.stream_url = "" + self.is_running = False + self.ffmpeg_proc = None + + # Socket.IO event handlers + self.sio.on('connect', self.on_connect) + self.sio.on('disconnect', self.on_disconnect) + self.sio.on('listener_count', self.on_listener_count) + self.sio.on('connect_error', self.on_connect_error) + + def on_connect(self): + print("[SOCKET] Connected to DJ server") + self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'}) + self.streaming_started.emit() + + def on_disconnect(self): + print("[SOCKET] Disconnected from DJ server") + + def on_connect_error(self, data): + self.streaming_error.emit(f"Connection error: {data}") + + def on_listener_count(self, data): + self.listener_count.emit(data.get('count', 0)) + + def run(self): + try: + # Connect to socket + self.sio.connect(self.stream_url) + + # Start FFmpeg to capture audio and output to pipe + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", "error", + "-f", "pulse", + "-i", "default.monitor", + "-ac", "2", + "-ar", "44100", + "-f", "mp3", + "-b:a", "128k", + "-af", "aresample=async=1", + "pipe:1" + ] + self.ffmpeg_proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192) + + while self.is_running and self.ffmpeg_proc.poll() is None: + chunk = self.ffmpeg_proc.stdout.read(8192) + if not chunk: break + if self.sio.connected: + self.sio.emit('audio_chunk', chunk) + + except Exception as e: + self.streaming_error.emit(f"Streaming thread error: {e}") + finally: + self.stop_streaming() + + def start_streaming(self, base_url, bitrate=128): + self.stream_url = base_url + self.is_running = True + self.start() + return True + + def stop_streaming(self): + self.is_running = False + if self.ffmpeg_proc: + try: self.ffmpeg_proc.terminate() + except: pass + self.ffmpeg_proc = None + if self.sio.connected: + self.sio.emit('stop_broadcast') + time.sleep(0.2) + self.sio.disconnect() + +# --- WIDGETS --- + +class GlowFrame(QWidget): + """Custom widget that paints a neon glow effect around the edges""" + def __init__(self, parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) # Don't block mouse events + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.glow_color = QColor("#0ff") + self.glow_enabled = False + + def set_glow(self, enabled, color="#0ff"): + self.glow_enabled = enabled + self.glow_color = QColor(color) + self.update() + + def paintEvent(self, event): + if not self.glow_enabled: + return + + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + rect = self.rect() + glow_width = 80 # Wider glow for more intensity + + # Draw glow from each edge using linear gradients + from PyQt6.QtGui import QLinearGradient + + # Top edge glow + top_gradient = QLinearGradient(0, 0, 0, glow_width) + for i in range(6): + pos = i / 5.0 + alpha = int(255 * (1 - pos)) # Full opacity at edge + color = QColor(self.glow_color) + color.setAlpha(alpha) + top_gradient.setColorAt(pos, color) + painter.fillRect(0, 0, rect.width(), glow_width, top_gradient) + + # Bottom edge glow + bottom_gradient = QLinearGradient(0, rect.height() - glow_width, 0, rect.height()) + for i in range(6): + pos = i / 5.0 + alpha = int(255 * pos) + color = QColor(self.glow_color) + color.setAlpha(alpha) + bottom_gradient.setColorAt(pos, color) + painter.fillRect(0, rect.height() - glow_width, rect.width(), glow_width, bottom_gradient) + + # Left edge glow + left_gradient = QLinearGradient(0, 0, glow_width, 0) + for i in range(6): + pos = i / 5.0 + alpha = int(255 * (1 - pos)) + color = QColor(self.glow_color) + color.setAlpha(alpha) + left_gradient.setColorAt(pos, color) + painter.fillRect(0, 0, glow_width, rect.height(), left_gradient) + + # Right edge glow + right_gradient = QLinearGradient(rect.width() - glow_width, 0, rect.width(), 0) + for i in range(6): + pos = i / 5.0 + alpha = int(255 * pos) + color = QColor(self.glow_color) + color.setAlpha(alpha) + right_gradient.setColorAt(pos, color) + painter.fillRect(rect.width() - glow_width, 0, glow_width, rect.height(), right_gradient) + +class VinylWidget(QWidget): + def __init__(self, color_hex, parent=None): + super().__init__(parent) + self.setMinimumSize(120, 120) + self.angle = 0 + self.speed = 1.0 + self.is_spinning = False + self.color = QColor(color_hex) + + # Initialize drawing resources + self.brush_disk = QBrush(QColor("#111")) + self.pen_disk = QPen(QColor("#000"), 2) + self.brush_label = QBrush(self.color) + self.brush_white = QBrush(Qt.GlobalColor.white) + self.center = QPointF(0, 0) + self.radius = 0 + + self.timer = QTimer(self) + self.timer.timeout.connect(self.rotate) + + def resizeEvent(self, event): + w, h = self.width(), self.height() + self.center = QPointF(w / 2, h / 2) + self.radius = min(w, h) / 2 - 5 + super().resizeEvent(event) + + def start_spin(self): + if not self.is_spinning: + self.is_spinning = True + self.timer.start(ANIMATION_INTERVAL) + + def stop_spin(self): + self.is_spinning = False + self.timer.stop() + + def set_speed(self, rate): + self.speed = rate + + def rotate(self): + self.angle = (self.angle + 3.0 * self.speed) % 360 + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.translate(self.center) + p.rotate(self.angle) + + # Draw vinyl disk + p.setBrush(self.brush_disk) + p.setPen(self.pen_disk) + p.drawEllipse(QPointF(0, 0), self.radius, self.radius) + + # Draw grooves + p.setBrush(Qt.BrushStyle.NoBrush) + p.setPen(QPen(QColor("#222"), 1)) + p.drawEllipse(QPointF(0, 0), self.radius * 0.8, self.radius * 0.8) + p.drawEllipse(QPointF(0, 0), self.radius * 0.6, self.radius * 0.6) + + # Draw center label + p.setBrush(self.brush_label) + p.setPen(Qt.PenStyle.NoPen) + p.drawEllipse(QPointF(0, 0), self.radius * 0.35, self.radius * 0.35) + + # Draw position marker + p.setBrush(self.brush_white) + p.drawRect(QRectF(-2, -self.radius * 0.35, 4, 12)) + +class WaveformWidget(QWidget): + seekRequested = pyqtSignal(int) + + def __init__(self, color_hex, parent=None): + super().__init__(parent) + self.color = QColor(color_hex) + self.setMinimumHeight(60) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self.duration = 1 + self.position = 0 + self.wave_data = [] + self.loop_active = False + self.loop_start = 0 + self.loop_end = 0 + self.last_seek_time = 0 + + # Initialize drawing resources + self.brush_active = QBrush(self.color) + self.brush_inactive = QBrush(QColor("#444")) + self.pen_white = QPen(QColor("#fff"), 2) + self.loop_brush = QBrush(QColor(255, 165, 0, 100)) + self.loop_pen = QPen(QColor("#ffa500"), 2) + + def generate_wave(self, file_path): + random.seed(str(file_path)) + self.wave_data = [max(0.1, random.random()**2) for _ in range(250)] + self.update() + + def set_duration(self, d): + self.duration = max(1, d) + self.update() + + def set_position(self, p): + self.position = p + self.update() + + def set_loop_region(self, active, start, end): + self.loop_active = active + self.loop_start = start + self.loop_end = end + self.update() + + def mousePressEvent(self, e): + if time.time() - self.last_seek_time > 0.1: + seek_pos = int((e.position().x() / self.width()) * self.duration) + self.seekRequested.emit(seek_pos) + self.last_seek_time = time.time() + + def mouseMoveEvent(self, e): + if time.time() - self.last_seek_time > 0.1: + seek_pos = int((e.position().x() / self.width()) * self.duration) + self.seekRequested.emit(seek_pos) + self.last_seek_time = time.time() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = self.width(), self.height() + p.fillRect(0, 0, w, h, QColor("#111")) + + if not self.wave_data: + p.setPen(self.pen_white) + p.drawLine(0, int(h / 2), w, int(h / 2)) + return + + bar_width = w / len(self.wave_data) + play_x = (self.position / self.duration) * w + p.setPen(Qt.PenStyle.NoPen) + + # Draw waveform bars + for i, val in enumerate(self.wave_data): + brush = self.brush_active if i * bar_width < play_x else self.brush_inactive + p.setBrush(brush) + bar_height = val * h * 0.9 + p.drawRect(QRectF(i * bar_width, (h - bar_height) / 2, bar_width, bar_height)) + + # Draw loop region + if self.loop_active: + loop_x = (self.loop_start / self.duration) * w + loop_width = ((self.loop_end - self.loop_start) / self.duration) * w + p.setBrush(self.loop_brush) + p.drawRect(QRectF(loop_x, 0, loop_width, h)) + p.setPen(self.loop_pen) + p.drawLine(int(loop_x), 0, int(loop_x), h) + p.drawLine(int(loop_x + loop_width), 0, int(loop_x + loop_width), h) + + # Draw playhead + p.setPen(self.pen_white) + p.drawLine(int(play_x), 0, int(play_x), h) + +class DeckWidget(QGroupBox): + def __init__(self, name, color_code, deck_id, parent=None): + super().__init__(name, parent) + self.setObjectName(name.replace(" ", "_")) + self.color_code = color_code + self.deck_id = deck_id + self.playback_mode = 0 + self.loop_active = False + self.loop_start = 0 + self.loop_end = 0 + self.loop_btns = [] + self.xf_vol = 100 + + self.loop_timer = QTimer(self) + self.loop_timer.setInterval(LOOP_CHECK_INTERVAL) + self.loop_timer.timeout.connect(self.check_loop) + + self.audio_output = QAudioOutput() + self.player = QMediaPlayer() + self.player.setAudioOutput(self.audio_output) + self.player.positionChanged.connect(self.on_position_changed) + self.player.durationChanged.connect(self.on_duration_changed) + self.player.mediaStatusChanged.connect(self.check_queue) + self.real_duration = 0 + + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout() + layout.setSpacing(5) + + # Top row: Vinyl and track info + r1 = QHBoxLayout() + self.vinyl = VinylWidget(self.color_code) + r1.addWidget(self.vinyl) + + c1 = QVBoxLayout() + self.lbl_tr = QLabel("NO MEDIA") + self.lbl_tr.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.lbl_tr.setStyleSheet( + f"color: {self.color_code}; border: 1px solid {self.color_code}; " + f"background: #000; padding: 4px;" + ) + self.lbl_tr.setWordWrap(True) + c1.addWidget(self.lbl_tr) + + rt = QHBoxLayout() + self.lbl_cur = QLabel("00:00") + self.lbl_cur.setStyleSheet("color:#fff") + self.lbl_tot = QLabel("00:00") + self.lbl_tot.setStyleSheet("color:#fff") + rt.addWidget(self.lbl_cur) + rt.addStretch() + rt.addWidget(self.lbl_tot) + c1.addLayout(rt) + r1.addLayout(c1) + layout.addLayout(r1) + + # Waveform + self.wave = WaveformWidget(self.color_code) + self.wave.seekRequested.connect(self.player.setPosition) + layout.addWidget(self.wave) + + # Loop buttons + g = QGridLayout() + g.setSpacing(2) + loops = [("8", 8), ("4", 4), ("2", 2), ("1", 1), ("1/2", 0.5), ("1/4", 0.25), ("1/8", 0.125)] + for i, (label, beats) in enumerate(loops): + btn = QPushButton(label) + btn.setObjectName("btn_loop") + btn.setCheckable(True) + btn.setToolTip(f"Set loop to {beats} beat(s)") + btn.clicked.connect(lambda c, b=beats, o=btn: self.set_loop(b, o)) + g.addWidget(btn, 0, i) + self.loop_btns.append(btn) + + exit_btn = QPushButton("EXIT") + exit_btn.setObjectName("btn_loop_exit") + exit_btn.setToolTip("Clear active loop") + exit_btn.clicked.connect(self.clear_loop) + g.addWidget(exit_btn, 0, len(loops)) + layout.addLayout(g) + + # Playback controls + rc = QHBoxLayout() + bp = QPushButton("PLAY") + bp.setToolTip("Play track") + bp.clicked.connect(self.play) + + bpa = QPushButton("PAUSE") + bpa.setToolTip("Pause playback") + bpa.clicked.connect(self.pause) + + bs = QPushButton("STOP") + bs.setToolTip("Stop playback") + bs.clicked.connect(self.stop) + + self.b_mode = QPushButton("MODE: CONT") + self.b_mode.setFixedWidth(100) + self.b_mode.setProperty("mode", "0") + self.b_mode.setToolTip("Cycle playback mode: Continuous / Loop 1 / Stop") + self.b_mode.clicked.connect(self.cycle_mode) + + rc.addWidget(bp) + rc.addWidget(bpa) + rc.addWidget(bs) + rc.addSpacing(10) + rc.addWidget(self.b_mode) + layout.addLayout(rc) + + # Pitch control + rp = QHBoxLayout() + self.sl_rate = QSlider(Qt.Orientation.Horizontal) + self.sl_rate.setRange(50, 150) + self.sl_rate.setValue(100) + self.sl_rate.setToolTip("Adjust playback speed / pitch") + self.sl_rate.valueChanged.connect(self.update_playback_rate) + + br = QPushButton("R") + br.setToolTip("Reset pitch to 1.0x") + br.clicked.connect(lambda: self.sl_rate.setValue(100)) + + self.lbl_rate = QLabel("1.0x") + self.lbl_rate.setStyleSheet("color:#fff; font-weight:bold;") + + rp.addWidget(QLabel("PITCH", styleSheet="color:#666")) + rp.addWidget(self.sl_rate) + rp.addWidget(br) + rp.addWidget(self.lbl_rate) + layout.addLayout(rp) + + # Bottom section: Queue and EQ + bottom = QHBoxLayout() + + # Queue widget + qc = QWidget() + ql = QVBoxLayout(qc) + ql.setContentsMargins(0, 0, 0, 0) + + hq = QHBoxLayout() + hq.addWidget(QLabel(f"QUEUE {self.deck_id}", styleSheet="font-size:10px; color:#666")) + bd = QPushButton("X") + bd.setObjectName("btn_remove") + bd.setToolTip("Remove selected track from queue") + bd.clicked.connect(self.delete_selected) + hq.addStretch() + hq.addWidget(bd) + ql.addLayout(hq) + + self.q_list = QListWidget() + self.q_list.setObjectName("queue_list") + self.q_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.q_list.setDefaultDropAction(Qt.DropAction.MoveAction) + self.q_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.q_list.itemDoubleClicked.connect( + lambda i: self.q_list.takeItem(self.q_list.row(i)) + ) + QShortcut(QKeySequence(Qt.Key.Key_Delete), self.q_list).activated.connect(self.delete_selected) + ql.addWidget(self.q_list) + + # EQ sliders widget + sc = QWidget() + sl = QVBoxLayout(sc) + sl.setContentsMargins(0, 0, 0, 0) + row_s = QHBoxLayout() + + def make_slider(prop, label, tooltip): + v = QVBoxLayout() + s = QSlider(Qt.Orientation.Vertical) + s.setRange(0, MAX_SLIDER_VALUE) + s.setValue(MAX_SLIDER_VALUE) + s.setProperty("eq", prop) + s.setToolTip(tooltip) + s.valueChanged.connect(self.recalc_vol) + l = QLabel(label) + l.setAlignment(Qt.AlignmentFlag.AlignCenter) + l.setStyleSheet("font-size:8px; color:#aaa;") + v.addWidget(s, 1, Qt.AlignmentFlag.AlignHCenter) + v.addWidget(l) + row_s.addLayout(v) + return s + + self.sl_vol = make_slider("vol", "LEV", "Volume level") + self.sl_hi = make_slider("high", "HI", "High frequencies (treble)") + self.sl_mid = make_slider("mid", "MID", "Mid frequencies") + self.sl_low = make_slider("low", "LO", "Low frequencies (bass)") + + sl.addLayout(row_s) + + if self.deck_id == "A": + bottom.addWidget(qc, 3) + bottom.addWidget(sc, 1) + else: + bottom.addWidget(sc, 1) + bottom.addWidget(qc, 3) + + layout.addLayout(bottom, 1) + self.setLayout(layout) + + def delete_selected(self): + for item in self.q_list.selectedItems(): + self.q_list.takeItem(self.q_list.row(item)) + + def cycle_mode(self): + self.playback_mode = (self.playback_mode + 1) % 3 + modes = {0: "CONT", 1: "LOOP 1", 2: "STOP"} + self.b_mode.setText(f"MODE: {modes[self.playback_mode]}") + self.b_mode.setProperty("mode", str(self.playback_mode)) + self.b_mode.style().unpolish(self.b_mode) + self.b_mode.style().polish(self.b_mode) + + def set_loop(self, beats, btn): + for x in self.loop_btns: + x.setChecked(x == btn) + + if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState: + return + + ms_per_beat = MS_PER_MINUTE / DEFAULT_BPM + self.loop_start = self.player.position() + self.loop_end = self.loop_start + int(ms_per_beat * beats) + self.loop_active = True + self.loop_timer.start() + self.wave.set_loop_region(True, self.loop_start, self.loop_end) + + def clear_loop(self): + self.loop_active = False + self.loop_timer.stop() + self.wave.set_loop_region(False, 0, 0) + for btn in self.loop_btns: + btn.setChecked(False) + + def check_loop(self): + if self.loop_active and self.player.position() >= self.loop_end: + self.player.setPosition(int(self.loop_start)) + + def load_track(self, path): + if not path: + return + + p = Path(path) + if not p.exists(): + print(f"[ERROR] Track path does not exist: {p}") + return + + try: + self.player.setSource(QUrl.fromLocalFile(str(p.absolute()))) + self.lbl_tr.setText(p.stem.upper()) + self.vinyl.set_speed(0) + self.vinyl.angle = 0 + self.vinyl.update() + self.wave.generate_wave(p) + # Find parent DJApp to show status + parent = self.window() + if hasattr(parent, 'status_label'): + parent.status_label.setText(f"Loaded: {p.name}") + + # Use soundfile to get accurate duration (GStreamer/Qt6 can be wrong) + try: + info = sf.info(str(p.absolute())) + self.real_duration = int(info.duration * 1000) + print(f"[DEBUG] {self.deck_id} Real Duration: {self.real_duration}ms") + # Update UI immediately if possible, or wait for player durationChanged + self.wave.set_duration(self.real_duration) + except Exception as se: + print(f"[DEBUG] Could not get accurate duration with soundfile: {se}") + self.real_duration = 0 + except Exception as e: + print(f"[ERROR] Failed to load track {p}: {e}") + self.lbl_tr.setText("LOAD ERROR") + + def add_queue(self, path): + p = Path(path) + item = QListWidgetItem(p.name) + item.setData(Qt.ItemDataRole.UserRole, p) + self.q_list.addItem(item) + + def check_queue(self, status): + if status == QMediaPlayer.MediaStatus.EndOfMedia: + # Check if this is a premature EndOfMedia (common in GStreamer with certain VBR MP3s) + if self.real_duration > 0 and self.player.position() < self.real_duration - 1000: + print(f"[DEBUG] {self.deck_id} Premature EndOfMedia detected. Position: {self.player.position()}, Expected: {self.real_duration}") + # Don't skip yet, maybe the user wants to seek back? + # Or we could try to play again, but usually GStreamer won't go further. + + if self.playback_mode == 1: + # Loop 1 mode + self.player.setPosition(0) + self.play() + elif self.playback_mode == 0 and self.q_list.count() > 0: + # Continuous mode - load next from queue + next_item = self.q_list.takeItem(0) + self.load_track(next_item.data(Qt.ItemDataRole.UserRole)) + self.play() + else: + # Stop mode or no queue items + self.stop() + + def play(self): + self.player.play() + self.vinyl.start_spin() + + def pause(self): + self.player.pause() + self.vinyl.stop_spin() + + def stop(self): + self.player.stop() + self.vinyl.stop_spin() + self.vinyl.angle = 0 + self.vinyl.update() + self.clear_loop() + + def on_position_changed(self, pos): + self.wave.set_position(pos) + minutes = int(pos // MS_PER_MINUTE) + seconds = int((pos // 1000) % 60) + self.lbl_cur.setText(f"{minutes:02}:{seconds:02}") + + def on_duration_changed(self, duration): + # Use our accurate duration if available, otherwise fallback to player's reported duration + final_duration = self.real_duration if self.real_duration > 0 else duration + self.wave.set_duration(final_duration) + minutes = int(final_duration // MS_PER_MINUTE) + seconds = int((final_duration // 1000) % 60) + self.lbl_tot.setText(f"{minutes:02}:{seconds:02}") + + def update_playback_rate(self, value): + rate = value / 100.0 + self.player.setPlaybackRate(rate) + self.lbl_rate.setText(f"{rate:.1f}x") + self.vinyl.set_speed(rate) + + def set_xf_vol(self, volume): + self.xf_vol = volume + self.recalc_vol() + + def recalc_vol(self): + eq_hi = self.sl_hi.value() / MAX_SLIDER_VALUE + eq_mid = self.sl_mid.value() / MAX_SLIDER_VALUE + eq_low = self.sl_low.value() / MAX_SLIDER_VALUE + eq_gain = eq_hi * eq_mid * eq_low + + final = (self.xf_vol / 100.0) * (self.sl_vol.value() / MAX_SLIDER_VALUE) * eq_gain + self.audio_output.setVolume(final) + +class DJApp(QMainWindow): def __init__(self): super().__init__() - - self.server_url = "http://54.37.246.24:5000" - self.cache_dir = Path.home() / ".techdj_cache" - self.cache_dir.mkdir(exist_ok=True) - - self.audio_engine = AudioEngine() - self.library = [] - self.download_threads = {} - self.broadcasting = False - self.broadcast_thread = None - self.listener_count = 0 - self.glow_enabled = {'A': False, 'B': False} - self.glow_intensity = 30 - self.deck_loading_target = {'A': None, 'B': None} - - # Socket.IO for broadcasting - self.socket = None - - # Library settings - self.library_mode = 'server' # 'server' or 'local' - self.server_library = [] - self.local_library = [] - self.local_folder = None - self.load_settings() - - # Search debounce timer - self.search_timer = QTimer() - self.search_timer.setSingleShot(True) - # Search is now fast enough to update quickly - self.search_timer.timeout.connect(lambda: self.update_library_list(rebuild=False)) - - self.init_ui() + self.setWindowTitle("TechDJ Pro - Neon Edition") + self.resize(1200, 950) + self.setStyleSheet(STYLESHEET) # Set window icon - icon_path = os.path.join(os.path.dirname(__file__), 'icon.png') + 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.audio_engine.start_stream() - self.fetch_library() + self.neon_state = 0 + + # --- LOCAL FOLDER SETUP --- + # Creates a folder named 'dj_tracks' inside your project directory + self.lib_path = Path(os.getcwd()) / "music" + if not self.lib_path.exists(): + try: + self.lib_path.mkdir(exist_ok=True) + print(f"[INIT] Created library folder: {self.lib_path}") + except Exception as e: + print(f"[ERROR] Could not create library folder: {e}") + + # Create recordings folder + self.recordings_path = Path(os.getcwd()) / "recordings" + if not self.recordings_path.exists(): + try: + self.recordings_path.mkdir(exist_ok=True) + print(f"[INIT] Created recordings folder: {self.recordings_path}") + except Exception as e: + print(f"[ERROR] Could not create recordings folder: {e}") + + self.search_worker = YTSearchWorker() + self.search_worker.results_ready.connect(self.on_search_results) + self.search_worker.error_occurred.connect(self.on_error) + + self.download_worker = YTDownloadWorker() + self.download_worker.download_finished.connect(self.on_download_complete) + self.download_worker.error_occurred.connect(self.on_error) + self.download_worker.download_progress.connect(self.update_download_progress) + + # Recording setup + self.recording_worker = RecordingWorker() + self.recording_worker.recording_error.connect(self.on_recording_error) + self.is_recording = False + self.recording_start_time = 0 + self.recording_timer = QTimer() + self.recording_timer.timeout.connect(self.update_recording_time) + + # Streaming setup + self.streaming_worker = StreamingWorker() + self.streaming_worker.streaming_error.connect(self.on_streaming_error) + self.streaming_worker.listener_count.connect(self.update_listener_count) + self.is_streaming = False + + # Server library state + self.server_url = "http://localhost:5000" + self.library_mode = "local" # "local" or "server" + self.server_library = [] + self.local_library = [] + self.cache_dir = Path.home() / ".techdj_cache" + self.cache_dir.mkdir(exist_ok=True) + self.download_threads = {} + + self.init_ui() + self.setup_keyboard_shortcuts() + self.apply_ui_settings() # Apply saved UI settings + + # Filtering debounce timer + self.filter_timer = QTimer() + self.filter_timer.setSingleShot(True) + self.filter_timer.timeout.connect(self.perform_filter) + + self.load_library() def init_ui(self): - self.setWindowTitle("TechDJ Pro - Native Edition") - self.setGeometry(50, 50, 1600, 900) + main = QWidget() + main.setObjectName("Central") # Set objectName for neon border styling + self.setCentralWidget(main) + layout = QVBoxLayout(main) + layout.setContentsMargins(15, 15, 15, 15) - # Central widget with overlay support - central = QWidget() - self.setCentralWidget(central) + # Create glow overlay + self.glow_frame = GlowFrame(main) + self.glow_frame.setGeometry(main.rect()) + self.glow_frame.raise_() # Bring to front - # Overall vertical layout for central widget - self.container_layout = QVBoxLayout(central) - self.container_layout.setContentsMargins(0, 0, 0, 0) - self.container_layout.setSpacing(0) + # Top bar: Neon toggle and YouTube search + h = QHBoxLayout() + self.neon_button = QPushButton("NEON EDGE: OFF") + self.neon_button.setObjectName("btn_neon") + self.neon_button.setFixedWidth(150) + self.neon_button.setToolTip("Toggle neon border effect") + self.neon_button.clicked.connect(self.toggle_neon) - # --- Download Bar (Minimized) --- - self.download_bar = QWidget() - self.download_bar.setFixedHeight(38) # Reduced from 50 - self.download_bar.setStyleSheet(f""" - QWidget {{ - background: rgb(20, 20, 30); - border-bottom: 1px solid rgba({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}, 0.3); - }} - """) - dl_layout = QHBoxLayout(self.download_bar) - dl_layout.setContentsMargins(10, 2, 10, 2) # Tighten margins + self.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.dl_input = QLineEdit() - self.dl_input.setPlaceholderText("Paste URL or Type to Search (YT, SC, etc.)") - self.dl_input.setStyleSheet(f""" - QLineEdit {{ - background: rgba(255, 255, 255, 0.05); - border: 1px solid #333; - color: white; - padding: 4px 12px; - border-radius: 12px; - font-family: 'Rajdhani'; - font-size: 12px; - }} - QLineEdit:focus {{ border: 1px solid rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); }} - """) - self.dl_input.returnPressed.connect(self.start_download) - dl_layout.addWidget(self.dl_input, 1) + # Format selector dropdown + self.format_selector = QComboBox() + self.format_selector.addItem("MP3 (slower, universal)", "mp3") + self.format_selector.addItem("Best Quality (faster)", "best") + self.format_selector.setCurrentIndex(0) # Default to MP3 + self.format_selector.setToolTip("Choose download format:\nMP3 = Converted, slower\nBest = Original quality, faster") + self.format_selector.setFixedWidth(160) - self.dl_btn = NeonButton("GET", SECONDARY_MAGENTA) # Shorter text - self.dl_btn.setFixedSize(60, 26) # Smaller button - self.dl_btn.clicked.connect(self.start_download) - dl_layout.addWidget(self.dl_btn) + self.search_button = QPushButton("GO") + self.search_button.setObjectName("btn_yt_go") + self.search_button.setFixedWidth(40) + self.search_button.setToolTip("Start YouTube search") + self.search_button.clicked.connect(self.search_youtube) - self.dl_progress = QProgressBar() - self.dl_progress.setFixedWidth(120) - self.dl_progress.setFixedHeight(4) - self.dl_progress.setTextVisible(False) - self.dl_progress.setStyleSheet(f""" - QProgressBar {{ background: #111; border: none; border-radius: 2px; }} - QProgressBar::chunk {{ background: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); }} - """) - self.dl_progress.hide() - dl_layout.addWidget(self.dl_progress) + self.settings_btn = QPushButton("MAP") + self.settings_btn.setFixedWidth(40) + self.settings_btn.setToolTip("Open Keyboard Mapping Settings") + self.settings_btn.clicked.connect(self.open_settings) - self.container_layout.addWidget(self.download_bar) + self.status_label = QLabel("") + self.status_label.setStyleSheet("color:#0f0; font-weight:bold") - # Main grid layout matching web panel - main_layout = QHBoxLayout() - # Create a widget to hold main_layout - self.app_content = QWidget() - self.app_content.setLayout(main_layout) - self.container_layout.addWidget(self.app_content, 1) - main_layout.setSpacing(10) - main_layout.setContentsMargins(10, 10, 10, 10) + h.addWidget(self.neon_button) + h.addSpacing(10) + h.addWidget(self.yt_input) + h.addWidget(self.format_selector) + h.addWidget(self.search_button) + h.addWidget(self.settings_btn) + h.addWidget(self.status_label) + layout.addLayout(h) - # Left: Library (320px) - library_widget = QWidget() - library_widget.setFixedWidth(320) - library_widget.setStyleSheet(f""" - QWidget {{ - background: rgba(20, 20, 30, 0.8); - border: 2px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); - border-radius: 10px; - }} - """) - - library_layout = QVBoxLayout(library_widget) - library_layout.setSpacing(10) - library_layout.setContentsMargins(15, 15, 15, 15) - - lib_header = QLabel("๐Ÿ“ LIBRARY") - lib_header.setStyleSheet(f""" - font-family: 'Orbitron'; - font-size: 16px; - font-weight: bold; - color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); - border: none; - """) - library_layout.addWidget(lib_header) - - # Library Mode Switch - mode_switch_layout = QHBoxLayout() - self.server_mode_btn = NeonButton("SERVER", PRIMARY_CYAN) - self.server_mode_btn.set_active(True) - self.server_mode_btn.clicked.connect(lambda: self.set_library_mode('server')) - - self.local_mode_btn = NeonButton("LOCAL", TEXT_DIM) - self.local_mode_btn.clicked.connect(lambda: self.set_library_mode('local')) - - mode_switch_layout.addWidget(self.server_mode_btn) - mode_switch_layout.addWidget(self.local_mode_btn) - library_layout.addLayout(mode_switch_layout) - - # Local Folder Selection (hidden by default) - self.local_folder_widget = QWidget() - local_folder_layout = QHBoxLayout(self.local_folder_widget) - local_folder_layout.setContentsMargins(0, 0, 0, 0) - - self.folder_label = QLabel("NO FOLDER...") - self.folder_label.setStyleSheet("color: #888; font-size: 10px;") - - select_folder_btn = QPushButton("๐Ÿ“") - select_folder_btn.setFixedSize(30, 30) - select_folder_btn.setStyleSheet("background: #333; border-radius: 4px; color: white;") - select_folder_btn.clicked.connect(self.select_local_folder) - - local_folder_layout.addWidget(self.folder_label, 1) - local_folder_layout.addWidget(select_folder_btn) - self.local_folder_widget.hide() - library_layout.addWidget(self.local_folder_widget) - - self.search_box = QLineEdit() - self.search_box.setPlaceholderText("๐Ÿ” FILTER LIBRARY...") - self.search_box.textChanged.connect(self.filter_library) - self.search_box.setStyleSheet(""" - QLineEdit { - background: rgba(0, 0, 0, 0.3); - border: 1px solid #333; - color: white; - padding: 10px; - border-radius: 4px; - font-family: 'Rajdhani'; + # Download progress bar + self.download_progress_bar = QProgressBar() + self.download_progress_bar.setRange(0, 100) + self.download_progress_bar.setValue(0) + self.download_progress_bar.setTextVisible(True) + self.download_progress_bar.setFormat("%p% - Downloading...") + self.download_progress_bar.setStyleSheet(""" + QProgressBar { + border: 1px solid #555; + border-radius: 3px; + text-align: center; + background-color: #111; + color: #fff; + } + QProgressBar::chunk { + background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #00ff00, stop:1 #00aa00); + border-radius: 2px; } """) - library_layout.addWidget(self.search_box) + self.download_progress_bar.setVisible(False) # Hidden by default + layout.addWidget(self.download_progress_bar) - self.library_list = QListWidget() - self.library_list.setSpacing(4) # Add spacing between items - self.library_list.setStyleSheet(""" - QListWidget { - background: rgba(0, 0, 0, 0.3); - border: none; - color: white; - outline: none; - } - QListWidget::item { - background: transparent; - border: none; - padding: 0px; - margin: 0px; - } - QListWidget::item:selected { - background: transparent; - } - """) - self.library_list.itemDoubleClicked.connect(self.on_library_double_click) - library_layout.addWidget(self.library_list) + # Decks + decks_layout = QHBoxLayout() + self.deck_a = DeckWidget("Deck A", "#00ffff", "A") + decks_layout.addWidget(self.deck_a) - refresh_btn = QPushButton("๐Ÿ”„ Refresh Library") - refresh_btn.clicked.connect(self.fetch_library) - refresh_btn.setStyleSheet(f""" - QPushButton {{ - background: rgba(0, 243, 255, 0.1); - border: 1px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); - color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); - padding: 8px 12px; - border-radius: 4px; - font-family: 'Orbitron'; - font-weight: bold; - }} - QPushButton:hover {{ - background: rgba(0, 243, 255, 0.2); - }} - """) - library_layout.addWidget(refresh_btn) + self.deck_b = DeckWidget("Deck B", "#ff00ff", "B") + decks_layout.addWidget(self.deck_b) - main_layout.addWidget(library_widget) - - # Right: Decks + Crossfader - decks_widget = QWidget() - decks_layout = QVBoxLayout(decks_widget) - decks_layout.setSpacing(10) - decks_layout.setContentsMargins(0, 0, 0, 0) - - # Decks grid - decks_grid = QHBoxLayout() - decks_grid.setSpacing(10) - - self.deck_a = DeckWidget('A', self.audio_engine) - decks_grid.addWidget(self.deck_a) - - self.deck_b = DeckWidget('B', self.audio_engine) - decks_grid.addWidget(self.deck_b) - - decks_layout.addLayout(decks_grid) + layout.addLayout(decks_layout, 70) # Crossfader - xfader_widget = QWidget() - xfader_widget.setFixedHeight(80) - xfader_widget.setStyleSheet(""" - QWidget { - background: qlineargradient(x1:0, y1:0, x1:0, y1:1, - stop:0 #1a1a1a, stop:1 #0a0a0a); - border: 2px solid #444; - border-radius: 8px; - } - """) - - # Crossfader Bar (Full Width) - xfader_widget = QWidget() - xfader_widget.setFixedHeight(80) - xfader_widget.setStyleSheet(""" - QWidget { - background: qlineargradient(x1:0, y1:0, x1:0, y1:1, - stop:0 #1a1a1a, stop:1 #0a0a0a); - border: 2px solid #444; - border-radius: 8px; - } - """) - - xfader_layout = QHBoxLayout(xfader_widget) - xfader_layout.setContentsMargins(40, 15, 40, 15) - - label_a = QLabel("A") - label_a.setStyleSheet(f""" - font-family: 'Orbitron'; - font-size: 24px; - font-weight: bold; - color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); - """) - xfader_layout.addWidget(label_a) - - self.crossfader = QSlider(Qt.Horizontal) + self.crossfader = QSlider(Qt.Orientation.Horizontal) + self.crossfader.setObjectName("crossfader") self.crossfader.setRange(0, 100) self.crossfader.setValue(50) - self.crossfader.valueChanged.connect(self.on_crossfader_change) - self.crossfader.setStyleSheet(""" - QSlider::groove:horizontal { - height: 12px; - background: qlineargradient(x1:0, y1:0, x1:1, y1:0, - stop:0 #00f3ff, stop:0.5 #333, stop:1 #bc13fe); - border-radius: 6px; - border: 2px solid #555; + self.crossfader.setToolTip("Crossfade between decks (Left = Deck A, Right = Deck B)") + self.crossfader.valueChanged.connect(self.update_crossfade) + + xf_layout = QVBoxLayout() + xf_layout.setContentsMargins(50, 5, 50, 5) + xf_layout.addWidget(self.crossfader) + layout.addLayout(xf_layout, 5) + + # Recording controls + rec_layout = QHBoxLayout() + rec_layout.setContentsMargins(50, 10, 50, 10) + + self.record_button = QPushButton("REC") + self.record_button.setFixedWidth(100) + self.record_button.setToolTip("Start/Stop recording your mix") + self.record_button.setStyleSheet(""" + QPushButton { + background-color: #330000; + color: #ff3333; + border: 2px solid #550000; + font-weight: bold; + font-size: 14px; + padding: 8px; } - QSlider::handle:horizontal { - background: qlineargradient(x1:0, y1:0, x1:0, y1:1, - stop:0 #aaa, stop:1 #666); - border: 3px solid #ccc; - width: 80px; - height: 48px; - margin: -18px 0; - border-radius: 8px; - } - QSlider::handle:horizontal:hover { - background: qlineargradient(x1:0, y1:0, x1:0, y1:1, - stop:0 #ccc, stop:1 #888); + QPushButton:hover { + background-color: #550000; + border-color: #ff0000; } """) - xfader_layout.addWidget(self.crossfader, 1) # Give it stretch + self.record_button.clicked.connect(self.toggle_recording) - label_b = QLabel("B") - label_b.setStyleSheet(f""" - font-family: 'Orbitron'; + self.recording_timer_label = QLabel("00:00") + self.recording_timer_label.setStyleSheet(""" + color: #888; font-size: 24px; font-weight: bold; - color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); + font-family: 'Courier New'; """) - xfader_layout.addWidget(label_b) + self.recording_timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - decks_layout.addWidget(xfader_widget) + self.recording_status_label = QLabel("Ready to record") + self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;") + self.recording_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - main_layout.addWidget(decks_widget, 1) - - # Floating action buttons (bottom right) - self.create_floating_buttons() - - # Streaming panel (hidden by default) - self.create_streaming_panel() - - # Settings panel (hidden by default) - self.create_settings_panel() - - # Window styling - self.setStyleSheet(f""" - QMainWindow {{ - background: rgb({BG_DARK.red()}, {BG_DARK.green()}, {BG_DARK.blue()}); - }} - QWidget {{ - color: rgb({TEXT_MAIN.red()}, {TEXT_MAIN.green()}, {TEXT_MAIN.blue()}); - font-family: 'Rajdhani', sans-serif; - }} - """) - - # Glow effect timer - self.glow_timer = QTimer() - self.glow_timer.timeout.connect(self.update_glow_effect) - self.glow_timer.start(100) - - def create_floating_buttons(self): - """Create floating action buttons in bottom-right corner""" - button_style = """ - QPushButton { - background: rgba(188, 19, 254, 0.2); - border: 2px solid #bc13fe; - color: white; - font-size: 20px; - border-radius: 25px; - padding: 10px; - } - QPushButton:hover { - background: rgba(188, 19, 254, 0.4); - } - """ + rec_left = QVBoxLayout() + rec_left.addWidget(self.recording_timer_label) + rec_left.addWidget(self.recording_status_label) # Streaming button - self.streaming_btn = QPushButton("๐Ÿ“ก", self) - self.streaming_btn.setFixedSize(50, 50) - self.streaming_btn.setStyleSheet(button_style) - self.streaming_btn.clicked.connect(self.toggle_streaming_panel) - self.streaming_btn.setToolTip("Live Streaming") - self.streaming_btn.move(self.width() - 70, self.height() - 280) - - # Settings button - self.settings_btn = QPushButton("โš™๏ธ", self) - self.settings_btn.setFixedSize(50, 50) - self.settings_btn.setStyleSheet(button_style) - self.settings_btn.clicked.connect(self.toggle_settings_panel) - self.settings_btn.setToolTip("Settings") - self.settings_btn.move(self.width() - 70, self.height() - 220) - - # Upload button - self.upload_btn = QPushButton("๐Ÿ“", self) - self.upload_btn.setFixedSize(50, 50) - self.upload_btn.setStyleSheet(button_style) - self.upload_btn.clicked.connect(self.upload_file) - self.upload_btn.setToolTip("Upload MP3") - self.upload_btn.move(self.width() - 70, self.height() - 160) - - # Keyboard shortcuts button - self.keyboard_btn = QPushButton("โŒจ๏ธ", self) - self.keyboard_btn.setFixedSize(50, 50) - self.keyboard_btn.setStyleSheet(button_style) - self.keyboard_btn.setToolTip("Keyboard Shortcuts") - self.keyboard_btn.move(self.width() - 70, self.height() - 100) - - def create_streaming_panel(self): - """Create streaming panel matching web version""" - self.streaming_panel = QWidget(self) - self.streaming_panel.setFixedSize(400, 500) - self.streaming_panel.setStyleSheet(f""" - QWidget {{ - background: rgba(20, 20, 30, 0.95); - border: 2px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); - border-radius: 10px; - }} - """) - self.streaming_panel.hide() - - layout = QVBoxLayout(self.streaming_panel) - layout.setSpacing(15) - - # Header - header = QHBoxLayout() - title = QLabel("๐Ÿ“ก LIVE STREAM") - title.setStyleSheet(f""" - font-family: 'Orbitron'; - font-size: 16px; - font-weight: bold; - color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); - """) - header.addWidget(title) - header.addStretch() - - close_btn = QPushButton("โœ•") - close_btn.setFixedSize(30, 30) - close_btn.clicked.connect(self.toggle_streaming_panel) - close_btn.setStyleSheet(""" + self.stream_button = QPushButton("LIVE") + self.stream_button.setFixedWidth(100) + self.stream_button.setToolTip("Start/Stop live streaming") + self.stream_button.setStyleSheet(""" QPushButton { - background: transparent; - border: none; - color: #888; - font-size: 18px; - } - QPushButton:hover { - color: white; - } - """) - header.addWidget(close_btn) - layout.addLayout(header) - - # Broadcast button - self.broadcast_btn = QPushButton("๐Ÿ”ด START BROADCAST") - self.broadcast_btn.setFixedHeight(60) - self.broadcast_btn.clicked.connect(self.toggle_broadcast) - self.broadcast_btn.setStyleSheet(f""" - QPushButton {{ - background: rgba(255, 0, 0, 0.2); - border: 2px solid #ff0000; - color: #ff0000; - font-family: 'Orbitron'; - font-size: 14px; + background-color: #001a33; + color: #3399ff; + border: 2px solid #003366; font-weight: bold; - border-radius: 8px; - }} - QPushButton:hover {{ - background: rgba(255, 0, 0, 0.3); - }} - """) - layout.addWidget(self.broadcast_btn) - - # Status - self.broadcast_status = QLabel("Offline") - self.broadcast_status.setAlignment(Qt.AlignCenter) - self.broadcast_status.setStyleSheet("color: #888; font-size: 12px;") - layout.addWidget(self.broadcast_status) - - # Listener count - listener_widget = QWidget() - listener_layout = QHBoxLayout(listener_widget) - listener_layout.setContentsMargins(0, 0, 0, 0) - - listener_icon = QLabel("๐Ÿ‘‚") - listener_icon.setStyleSheet("font-size: 24px;") - listener_layout.addWidget(listener_icon) - - self.listener_count_label = QLabel("0") - self.listener_count_label.setStyleSheet(f""" - font-family: 'Orbitron'; - font-size: 32px; - font-weight: bold; - color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); - """) - listener_layout.addWidget(self.listener_count_label) - - listener_text = QLabel("Listeners") - listener_text.setStyleSheet("color: #888; font-size: 14px;") - listener_layout.addWidget(listener_text) - listener_layout.addStretch() - - layout.addWidget(listener_widget) - - # Stream URL - url_label = QLabel("Share this URL:") - url_label.setStyleSheet("color: #888; font-size: 12px;") - layout.addWidget(url_label) - - url_widget = QWidget() - url_layout = QHBoxLayout(url_widget) - url_layout.setContentsMargins(0, 0, 0, 0) - url_layout.setSpacing(5) - - self.stream_url = QLineEdit("http://localhost:5001") - self.stream_url.setReadOnly(True) - self.stream_url.setStyleSheet(""" - QLineEdit { - background: rgba(0, 0, 0, 0.3); - border: 1px solid #333; - color: white; + font-size: 14px; padding: 8px; - border-radius: 4px; - } - """) - url_layout.addWidget(self.stream_url) - - copy_btn = QPushButton("๐Ÿ“‹") - copy_btn.setFixedSize(40, 30) - copy_btn.clicked.connect(self.copy_stream_url) - copy_btn.setStyleSheet(""" - QPushButton { - background: rgba(0, 243, 255, 0.1); - border: 1px solid #00f3ff; - color: #00f3ff; } QPushButton:hover { - background: rgba(0, 243, 255, 0.2); + background-color: #003366; + border-color: #0066cc; } """) - url_layout.addWidget(copy_btn) + self.stream_button.clicked.connect(self.toggle_streaming) - layout.addWidget(url_widget) + self.stream_status_label = QLabel("Offline") + self.stream_status_label.setStyleSheet("color: #666; font-size: 12px;") + self.stream_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - # Auto-start checkbox - self.auto_start_check = QCheckBox("Auto-start on play") - self.auto_start_check.setStyleSheet("color: #e0e0e0;") - layout.addWidget(self.auto_start_check) + self.listener_count_label = QLabel("0 listeners") + self.listener_count_label.setStyleSheet("color: #3399ff; font-size: 10px; font-weight: bold;") + self.listener_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - # Quality selector - quality_label = QLabel("Stream Quality:") - quality_label.setStyleSheet("color: #888; font-size: 12px;") - layout.addWidget(quality_label) + stream_info = QVBoxLayout() + stream_info.addWidget(self.stream_status_label) + stream_info.addWidget(self.listener_count_label) - self.quality_combo = QComboBox() - self.quality_combo.addItems([ - "High (128kbps)", - "Medium (96kbps)", - "Low (64kbps)", - "Very Low (48kbps)", - "Minimum (32kbps)" - ]) - self.quality_combo.setCurrentIndex(1) - self.quality_combo.setStyleSheet(""" - QComboBox { - background: rgba(0, 0, 0, 0.3); - border: 1px solid #333; - color: white; - padding: 5px; - border-radius: 4px; - } - QComboBox::drop-down { - border: none; - } - QComboBox QAbstractItemView { - background: #1a1a1a; - color: white; - selection-background-color: #00f3ff; - } - """) - layout.addWidget(self.quality_combo) + rec_layout.addStretch() + rec_layout.addWidget(self.record_button) + rec_layout.addSpacing(20) + rec_layout.addLayout(rec_left) + rec_layout.addSpacing(40) + rec_layout.addWidget(self.stream_button) + rec_layout.addSpacing(20) + rec_layout.addLayout(stream_info) + rec_layout.addStretch() - hint = QLabel("Lower = more stable on poor connections") - hint.setStyleSheet("color: #666; font-size: 10px;") - layout.addWidget(hint) + layout.addLayout(rec_layout, 3) - layout.addStretch() + # Library section + library_group = QGroupBox("LIBRARY") + lib_layout = QVBoxLayout(library_group) - # Position panel - self.streaming_panel.move(self.width() - 420, 20) + button_row = QHBoxLayout() + + self.local_mode_btn = QPushButton("LOCAL") + self.local_mode_btn.setObjectName("btn_lib_local") + self.local_mode_btn.setCheckable(True) + self.local_mode_btn.setChecked(True) + self.local_mode_btn.clicked.connect(lambda: self.set_library_mode("local")) + + self.server_mode_btn = QPushButton("SERVER") + self.server_mode_btn.setObjectName("btn_lib_server") + self.server_mode_btn.setCheckable(True) + self.server_mode_btn.clicked.connect(lambda: self.set_library_mode("server")) + + refresh_btn = QPushButton("REFRESH") + refresh_btn.setToolTip("Rescan library folder for audio files") + refresh_btn.clicked.connect(self.load_library) + + upload_btn = QPushButton("UPLOAD") + upload_btn.setToolTip("Upload track to library") + upload_btn.clicked.connect(self.upload_track) + + load_a_btn = QPushButton("LOAD A") + load_a_btn.setToolTip("Load selected track to Deck A (Ctrl+L)") + load_a_btn.clicked.connect(lambda: self.load_to_deck(self.deck_a)) + + queue_a_btn = QPushButton("Q A+") + queue_a_btn.setToolTip("Add selected track to Deck A queue (Ctrl+Shift+L)") + queue_a_btn.clicked.connect(lambda: self.queue_to_deck(self.deck_a)) + + load_b_btn = QPushButton("LOAD B") + load_b_btn.setToolTip("Load selected track to Deck B (Ctrl+R)") + load_b_btn.clicked.connect(lambda: self.load_to_deck(self.deck_b)) + + queue_b_btn = QPushButton("Q B+") + queue_b_btn.setToolTip("Add selected track to Deck B queue (Ctrl+Shift+R)") + queue_b_btn.clicked.connect(lambda: self.queue_to_deck(self.deck_b)) + + for btn in [self.local_mode_btn, self.server_mode_btn, refresh_btn, upload_btn, load_a_btn, queue_a_btn, load_b_btn, queue_b_btn]: + button_row.addWidget(btn) + lib_layout.addLayout(button_row) + + self.search_filter = QLineEdit() + self.search_filter.setPlaceholderText("Filter...") + self.search_filter.setToolTip("Filter library by filename") + self.search_filter.textChanged.connect(self.filter_library) + lib_layout.addWidget(self.search_filter) + + self.library_list = QListWidget() + self.library_list.setObjectName("main_lib") + lib_layout.addWidget(self.library_list) + + layout.addWidget(library_group, 25) + + # Initialize crossfade + self.update_crossfade() - def create_settings_panel(self): - """Create settings panel with glow controls""" - self.settings_panel = QWidget(self) - self.settings_panel.setFixedSize(400, 600) - self.settings_panel.setStyleSheet(f""" - QWidget {{ - background: rgba(20, 20, 30, 0.95); - border: 2px solid rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); - border-radius: 10px; - }} - """) - self.settings_panel.hide() + def setup_keyboard_shortcuts(self): + # Clear existing shortcuts if any + if hasattr(self, '_shortcuts'): + for s in self._shortcuts: + s.setParent(None) + self._shortcuts = [] - layout = QVBoxLayout(self.settings_panel) - layout.setSpacing(10) + # 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"), + } - # Header - header = QHBoxLayout() - title = QLabel("โš™๏ธ SETTINGS") - title.setStyleSheet(f""" - font-family: 'Orbitron'; - font-size: 16px; - font-weight: bold; - color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); - """) - header.addWidget(title) - header.addStretch() + # 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", + } - close_btn = QPushButton("โœ•") - close_btn.setFixedSize(30, 30) - close_btn.clicked.connect(self.toggle_settings_panel) - close_btn.setStyleSheet(""" - QPushButton { - background: transparent; - border: none; - color: #888; - font-size: 18px; - } - QPushButton:hover { - color: white; - } - """) - header.addWidget(close_btn) - layout.addLayout(header) - - # Settings checkboxes - checkbox_style = """ - QCheckBox { - color: #e0e0e0; - font-size: 13px; - spacing: 8px; - } - QCheckBox::indicator { - width: 18px; - height: 18px; - border: 2px solid #666; - border-radius: 3px; - background: rgba(0, 0, 0, 0.3); - } - QCheckBox::indicator:checked { - background: #bc13fe; - border-color: #bc13fe; - } - """ - - self.repeat_a_check = QCheckBox("๐Ÿ” Repeat Deck A") - self.repeat_a_check.setStyleSheet(checkbox_style) - layout.addWidget(self.repeat_a_check) - - self.repeat_b_check = QCheckBox("๐Ÿ” Repeat Deck B") - self.repeat_b_check.setStyleSheet(checkbox_style) - layout.addWidget(self.repeat_b_check) - - self.auto_mix_check = QCheckBox("๐ŸŽ›๏ธ Auto-Crossfade") - self.auto_mix_check.setStyleSheet(checkbox_style) - layout.addWidget(self.auto_mix_check) - - self.shuffle_check = QCheckBox("๐Ÿ”€ Shuffle Library") - self.shuffle_check.setStyleSheet(checkbox_style) - layout.addWidget(self.shuffle_check) - - self.quantize_check = QCheckBox("๐Ÿ“ Quantize") - self.quantize_check.setStyleSheet(checkbox_style) - layout.addWidget(self.quantize_check) - - self.auto_play_check = QCheckBox("โ–ถ๏ธ Auto-play next") - self.auto_play_check.setChecked(True) - self.auto_play_check.setStyleSheet(checkbox_style) - layout.addWidget(self.auto_play_check) - - # Glow controls - layout.addWidget(QLabel("")) # Spacer - - glow_title = QLabel("โœจ NEON GLOW EFFECTS") - glow_title.setStyleSheet(f""" - font-family: 'Orbitron'; - font-size: 14px; - font-weight: bold; - color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); - """) - layout.addWidget(glow_title) - - self.glow_a_check = QCheckBox("โœจ Glow Deck A (Cyan)") - self.glow_a_check.setStyleSheet(checkbox_style) - self.glow_a_check.stateChanged.connect(lambda: self.toggle_glow('A')) - layout.addWidget(self.glow_a_check) - - self.glow_b_check = QCheckBox("โœจ Glow Deck B (Magenta)") - self.glow_b_check.setStyleSheet(checkbox_style) - self.glow_b_check.stateChanged.connect(lambda: self.toggle_glow('B')) - layout.addWidget(self.glow_b_check) - - # Glow intensity - intensity_label = QLabel("โœจ Glow Intensity") - intensity_label.setStyleSheet("color: #e0e0e0; font-size: 13px;") - layout.addWidget(intensity_label) - - self.glow_slider = QSlider(Qt.Horizontal) - self.glow_slider.setRange(1, 100) - self.glow_slider.setValue(30) - self.glow_slider.valueChanged.connect(self.update_glow_intensity) - self.glow_slider.setStyleSheet(""" - QSlider::groove:horizontal { - height: 8px; - background: #333; - border-radius: 4px; - } - QSlider::handle:horizontal { - background: #bc13fe; - border: 2px solid #bc13fe; - width: 16px; - margin: -4px 0; - border-radius: 8px; - } - """) - layout.addWidget(self.glow_slider) - - # Server URL configuration - layout.addWidget(QLabel("")) # Spacer - server_title = QLabel("๐Ÿ“ก SERVER CONFIGURATION") - server_title.setStyleSheet(f""" - font-family: 'Orbitron'; - font-size: 14px; - font-weight: bold; - color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); - """) - layout.addWidget(server_title) - - server_url_label = QLabel("๐Ÿ”— Server API URL (e.g. http://localhost:5000)") - server_url_label.setStyleSheet("color: #e0e0e0; font-size: 13px;") - layout.addWidget(server_url_label) - - self.server_url_input = QLineEdit(self.server_url) - self.server_url_input.setStyleSheet(""" - background: rgba(0, 0, 0, 0.4); - border: 1px solid #444; - color: cyan; - padding: 5px; - font-family: 'Rajdhani'; - border-radius: 4px; - """) - self.server_url_input.textChanged.connect(self.on_server_url_change) - layout.addWidget(self.server_url_input) - - layout.addStretch() - - # Position panel - self.settings_panel.move(self.width() - 420, 20) + # 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_settings(self): - """Load persistent settings""" - settings_path = Path.home() / ".techdj_settings.json" - if settings_path.exists(): + 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(settings_path, 'r') as f: - data = json.load(f) - self.local_folder = data.get('local_folder') - self.library_mode = data.get('library_mode', 'server') - self.server_url = data.get('server_url', self.server_url) + 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"Error loading settings: {e}") - - def save_settings(self): - """Save persistent settings""" - settings_path = Path.home() / ".techdj_settings.json" - try: - with open(settings_path, 'w') as f: - json.dump({ - 'local_folder': self.local_folder, - 'library_mode': self.library_mode, - 'server_url': self.server_url - }, f) - except Exception as e: - print(f"Error saving settings: {e}") - - def set_library_mode(self, mode): - """Switch between server and local library""" - self.library_mode = mode + 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) - if mode == 'server': - self.server_mode_btn.set_active(True) - self.local_mode_btn.set_active(False) - self.local_folder_widget.hide() - else: - self.server_mode_btn.set_active(False) - self.local_mode_btn.set_active(True) - self.local_folder_widget.show() - if self.local_folder: - self.folder_label.setText(os.path.basename(self.local_folder).upper()) - self.scan_local_library() + # Apply neon mode + if neon_mode != self.neon_state: + for _ in range(neon_mode): + self.toggle_neon() - self.update_library_list(rebuild=True) - self.save_settings() - - def select_local_folder(self): - """Open dialog to select local music folder""" - folder = QFileDialog.getExistingDirectory(self, "Select Music Folder") - if folder: - self.local_folder = folder - self.folder_label.setText(os.path.basename(folder).upper()) - self.scan_local_library() - self.update_library_list(rebuild=True) - self.save_settings() - - def on_server_url_change(self, text): - """Update server URL and save""" - self.server_url = text - self.save_settings() - - # Debounce the refresh to avoid spamming while typing - if not hasattr(self, '_refresh_timer'): - self._refresh_timer = QTimer() - self._refresh_timer.timeout.connect(self.fetch_library) - self._refresh_timer.setSingleShot(True) - - self._refresh_timer.start(1500) # Refresh library 1.5s after typing stops - - def scan_local_library(self): - """Scan local folder for audio files""" - if not self.local_folder: + # Apply library settings + library_settings = self.all_settings.get("library", {}) + yt_default = library_settings.get("yt_default_format", "mp3") + self.format_selector.setCurrentIndex(0 if yt_default == "mp3" else 1) + + def open_settings(self): + dialog = SettingsDialog(self.all_settings, self) + if dialog.exec(): + # Get all updated settings + self.all_settings = dialog.get_all_settings() + self.current_shortcuts = self.all_settings["shortcuts"] + + # Save all settings + with open(self.settings_file, "w") as f: + json.dump(self.all_settings, f, indent=4) + + # Re-setup shortcuts + self.setup_keyboard_shortcuts() + + # Apply UI settings + self.apply_ui_settings() + + QMessageBox.information(self, "Settings Saved", "All settings have been updated!") + + def search_youtube(self): + query = self.yt_input.text().strip() + if not query: return - - self.local_library = [] - extensions = ('.mp3', '.wav', '.flac', '.ogg', '.m4a') - - try: - for root, dirs, files in os.walk(self.local_folder): - for file in sorted(files): - if file.lower().endswith(extensions): - full_path = os.path.join(root, file) - self.local_library.append({ - "title": os.path.splitext(file)[0], - "file": full_path, - "is_local": True - }) - print(f"๐Ÿ“‚ Found {len(self.local_library)} local tracks") - except Exception as e: - print(f"Error scanning folder: {e}") - def fetch_library(self): - try: - response = requests.get(f"{self.server_url}/library.json", timeout=5) - self.server_library = response.json() - # Mark server tracks - for track in self.server_library: - track['is_local'] = False + # 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...") - # Initial mode setup - self.set_library_mode(self.library_mode) - print(f"๐Ÿ“š Loaded {len(self.server_library)} tracks from server") - except Exception as e: - print(f"โŒ Error fetching library: {e}") - # Still set local mode if server fails - self.set_library_mode(self.library_mode) + # Show and reset progress bar + self.download_progress_bar.setValue(0) + self.download_progress_bar.setVisible(True) + + self.download_worker.download(query, str(self.lib_path), selected_format) + self.yt_input.clear() + else: + # Keyword Search mode + self.status_label.setText("Searching...") + self.search_button.setEnabled(False) + self.search_worker.search(query) + + def on_search_results(self, results): + self.status_label.setText("") + self.search_button.setEnabled(True) + dialog = YTResultDialog(results, self) + if dialog.exec(): + url = dialog.get_selected_url() + if url: + # Get selected format from dropdown + selected_format = self.format_selector.currentData() + self.status_label.setText("Downloading...") + + # Show and reset progress bar + self.download_progress_bar.setValue(0) + self.download_progress_bar.setVisible(True) + + self.download_worker.download(url, str(self.lib_path), selected_format) + + def update_download_progress(self, percentage): + """Update download progress bar""" + self.download_progress_bar.setValue(int(percentage)) + + def on_download_complete(self, filepath): + self.status_label.setText("Done!") + self.download_progress_bar.setVisible(False) # Hide progress bar + self.load_library() + QMessageBox.information(self, "Download Complete", f"Saved: {os.path.basename(filepath)}") + self.status_label.setText("") + + def on_error(self, error_msg): + self.status_label.setText("Error") + self.search_button.setEnabled(True) + self.download_progress_bar.setVisible(False) # Hide progress bar on error + QMessageBox.critical(self, "Error", error_msg) + + def toggle_neon(self): + self.neon_state = (self.neon_state + 1) % 3 + colors = {0: "#555", 1: "#0ff", 2: "#f0f"} + color = colors[self.neon_state] + labels = ["OFF", "BLUE", "PURPLE"] + + self.neon_button.setText(f"NEON EDGE: {labels[self.neon_state]}") + self.neon_button.setStyleSheet(f"color: {color}; border: 1px solid {color};") + + if self.neon_state == 0: + # Disable glow + self.glow_frame.set_glow(False) + self.centralWidget().setStyleSheet("QWidget#Central { border: none; }") + else: + # Enable glow with selected color + self.glow_frame.set_glow(True, color) + self.centralWidget().setStyleSheet("QWidget#Central { border: none; }") + + def set_library_mode(self, mode): + self.library_mode = mode + self.local_mode_btn.setChecked(mode == "local") + self.server_mode_btn.setChecked(mode == "server") + self.load_library() - def update_library_list(self, rebuild=False): - """Update library results. If rebuild is True, clear and recreate all widgets. - If rebuild is False, just hide/show existing items (much faster).""" - search_term = self.search_box.text().lower() - - # Determine which library to show - library_to_show = self.server_library if self.library_mode == 'server' else self.local_library - - # If we need a full rebuild or the list is empty/wrong size - if rebuild or self.library_list.count() != len(library_to_show): - self.library_list.setUpdatesEnabled(False) + def load_library(self): + if self.library_mode == "local": self.library_list.clear() - - for track in library_to_show: - item = QListWidgetItem() - item.setSizeHint(QSize(0, 40)) - item.setData(Qt.UserRole, track) - - widget = QWidget() - widget.setStyleSheet(""" - QWidget { - background: rgba(255, 255, 255, 0.03); - border-radius: 4px; - border-left: 3px solid transparent; - } - QWidget:hover { - background: rgba(255, 255, 255, 0.08); - border-left: 3px solid #00f3ff; - } - """) - item_layout = QHBoxLayout(widget) - item_layout.setContentsMargins(10, 8, 10, 8) - item_layout.setSpacing(5) - - label = QLabel(track['title']) - label.setStyleSheet("font-family: 'Rajdhani'; font-weight: bold; font-size: 13px; color: white; background: transparent;") - item_layout.addWidget(label, 1) - - btn_a = QPushButton("A+") - btn_a.setFixedSize(30, 22) - btn_a.setStyleSheet(f"background: rgba(0, 243, 255, 0.2); border: 1px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); border-radius: 4px; color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); font-size: 9px; font-weight: bold;") - btn_a.clicked.connect(lambda _, t=track: self.add_to_queue('A', t)) - item_layout.addWidget(btn_a) - - btn_b = QPushButton("B+") - btn_b.setFixedSize(30, 22) - btn_b.setStyleSheet(f"background: rgba(188, 19, 254, 0.2); border: 1px solid rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); border-radius: 4px; color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); font-size: 9px; font-weight: bold;") - btn_b.clicked.connect(lambda _, t=track: self.add_to_queue('B', t)) - item_layout.addWidget(btn_b) - - self.library_list.addItem(item) - self.library_list.setItemWidget(item, widget) - - self.library_list.setUpdatesEnabled(True) + 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() - # Apply visibility filter (extremely fast) + def fetch_server_library(self): + self.library_list.clear() + self.library_list.addItem("Fetching server library...") + + base_url = self.get_server_base_url() + self.server_url = base_url + + self.fetcher = ServerLibraryFetcher(f"{base_url}/library.json") + self.fetcher.finished.connect(lambda tracks, err, success: self.on_server_library_fetched(tracks, base_url, err, success)) + self.fetcher.start() + + def on_server_library_fetched(self, tracks, base_url, err, success): + self.library_list.clear() + if success: + self.server_library = tracks + self.populate_server_library(tracks, base_url) + else: + self.library_list.addItem(f"Error: {err}") + + def populate_server_library(self, tracks, base_url): + self.library_list.clear() + for track in tracks: + item = QListWidgetItem(track['title']) + # Store URL and title + track_url = f"{base_url}/{track['file']}" + item.setData(Qt.ItemDataRole.UserRole, {"url": track_url, "title": track['title'], "is_server": True}) + self.library_list.addItem(item) + + def populate_library(self, files): + self.library_list.clear() + self.local_library = [] + for file_path in files: + item = QListWidgetItem(file_path.name) + data = {"path": file_path, "title": file_path.stem, "is_server": False} + item.setData(Qt.ItemDataRole.UserRole, data) + self.library_list.addItem(item) + self.local_library.append(data) + + def filter_library(self, filter_text): + # Debounce the search to prevent UI freezing while typing + self.filter_timer.start(250) + + def perform_filter(self): + filter_text = self.search_filter.text().lower().strip() self.library_list.setUpdatesEnabled(False) for i in range(self.library_list.count()): item = self.library_list.item(i) - track = item.data(Qt.UserRole) - if track: - visible = not search_term or search_term in track['title'].lower() - item.setHidden(not visible) + is_match = not filter_text or filter_text in item.text().lower() + item.setHidden(not is_match) self.library_list.setUpdatesEnabled(True) - - def filter_library(self): - # Debounce reduced to 100ms for snappier feel - self.search_timer.start(100) - def on_library_double_click(self, item): - track = item.data(Qt.UserRole) - - dialog = QDialog(self) - dialog.setWindowTitle("Load Track") - dialog.setStyleSheet(f""" - QDialog {{ - background: rgb({BG_DARK.red()}, {BG_DARK.green()}, {BG_DARK.blue()}); - }} - """) - - layout = QVBoxLayout() - layout.addWidget(QLabel(f"Load '{track['title']}' to:")) - - btn_a = NeonButton(f"โ–ถ Play on Deck A", PRIMARY_CYAN) - btn_a.clicked.connect(lambda: self.load_to_deck('A', track, dialog)) - layout.addWidget(btn_a) - - btn_b = NeonButton(f"โ–ถ Play on Deck B", SECONDARY_MAGENTA) - btn_b.clicked.connect(lambda: self.load_to_deck('B', track, dialog)) - layout.addWidget(btn_b) - - # Add to queue buttons - queue_a = NeonButton(f"๐Ÿ“‹ Add to Queue A", PRIMARY_CYAN) - queue_a.clicked.connect(lambda: self.add_to_queue('A', track, dialog)) - layout.addWidget(queue_a) - - queue_b = NeonButton(f"๐Ÿ“‹ Add to Queue B", SECONDARY_MAGENTA) - queue_b.clicked.connect(lambda: self.add_to_queue('B', track, dialog)) - layout.addWidget(queue_b) - - dialog.setLayout(layout) - dialog.exec_() - - def load_to_deck(self, deck_id, track, dialog=None): - if dialog: - dialog.accept() - - if track.get('is_local'): - # Load local file directly - print(f"๐Ÿ“‚ Loading local: {track['file']}") - self.deck_loading_target[deck_id] = track['file'] - if deck_id == 'A': - self.deck_a.load_track(track['file']) - else: - self.deck_b.load_track(track['file']) - return + 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) - filename = os.path.basename(track['file']) + 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 - self.deck_loading_target[deck_id] = str(cache_path) if cache_path.exists(): - print(f"๐Ÿ“ฆ Using cached: {filename}") - if deck_id == 'A': - self.deck_a.load_track(str(cache_path)) - else: - self.deck_b.load_track(str(cache_path)) + deck.load_track(cache_path) else: - url = f"{self.server_url}/{track['file']}" - print(f"โฌ‡๏ธ Downloading: {filename}") - + self.status_label.setText(f"Downloading: {title}...") thread = DownloadThread(url, str(cache_path)) - thread.finished.connect(lambda path, success: self.on_download_finished(deck_id, path, success)) + thread.finished.connect(lambda path, success: self.on_server_download_complete(deck, path, success)) thread.start() self.download_threads[filename] = thread - - def on_download_finished(self, deck_id, filepath, success): - if success: - # Check if this is still the intended track for this deck - if self.deck_loading_target.get(deck_id) != filepath: - print(f"โญ๏ธ Stale download finished (ignored): {os.path.basename(filepath)}") - return - print(f"โœ… Downloaded: {os.path.basename(filepath)}") - if deck_id == 'A': - self.deck_a.load_track(filepath) - else: - self.deck_b.load_track(filepath) - else: - QMessageBox.warning(self, "Download Error", "Failed to download track") - - def add_to_queue(self, deck_id, track, dialog=None): - """Add track to deck's queue""" - if dialog: - dialog.accept() - - # Determine file path - if self.library_mode == 'local': - filepath = track['file'] - else: - filename = track['file'].split('/')[-1] - cache_path = self.cache_dir / filename - - if cache_path.exists(): - filepath = str(cache_path) - else: - # Download to cache first - url = f"{self.server_url}/{track['file']}" - print(f"โฌ‡๏ธ Downloading for queue: {filename}") - - thread = DownloadThread(url, str(cache_path)) - thread.finished.connect(lambda path, success: self.on_queue_download_finished(deck_id, path, success)) - thread.start() - self.download_threads[filename] = thread - return - - # Add to queue - self.audio_engine.add_to_queue(deck_id, filepath) - queue_len = len(self.audio_engine.get_queue(deck_id)) - print(f"๐Ÿ“‹ Added to Deck {deck_id} queue: {track['title']} (Queue: {queue_len})") - - if dialog: - QMessageBox.information(self, "Added to Queue", - f"Added '{track['title']}' to Deck {deck_id} queue\n\nQueue length: {queue_len}") - - def on_queue_download_finished(self, deck_id, filepath, success): - """Handle download completion for queued tracks""" + def on_server_download_complete(self, deck, path, success): + self.status_label.setText("Download complete") if success: - self.audio_engine.add_to_queue(deck_id, filepath) - queue_len = len(self.audio_engine.get_queue(deck_id)) - print(f"โœ… Downloaded and queued: {os.path.basename(filepath)} (Queue: {queue_len})") + deck.load_track(Path(path)) else: - print(f"โŒ Failed to download for queue: {os.path.basename(filepath)}") + 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 on_crossfader_change(self, value): - self.audio_engine.set_crossfader(value / 100.0) - - - def toggle_streaming_panel(self): - """Toggle streaming panel visibility""" - if self.streaming_panel.isVisible(): - self.streaming_panel.hide() - else: - self.settings_panel.hide() # Hide settings if open - self.streaming_panel.show() - self.streaming_panel.raise_() - - def toggle_settings_panel(self): - """Toggle settings panel visibility""" - if self.settings_panel.isVisible(): - self.settings_panel.hide() - else: - self.streaming_panel.hide() # Hide streaming if open - self.settings_panel.show() - self.settings_panel.raise_() - - def toggle_broadcast(self): - """Toggle broadcast on/off""" - if not self.broadcasting: - # Start broadcast + def upload_track(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Select Track to Upload", "", "Audio Files (*.mp3 *.wav *.m4a *.flac *.ogg)") + if not file_path: + return + + if self.library_mode == "local": + # Copy to local music folder + dest = self.lib_path / os.path.basename(file_path) try: - if self.socket is None: - print(f"๐Ÿ”Œ Connecting to server: {self.server_url}") - self.socket = socketio.Client(logger=True, engineio_logger=False) - - # Add connection event handlers - @self.socket.on('connect') - def on_connect(): - print("โœ… Socket.IO connected successfully") - - @self.socket.on('connect_error') - def on_connect_error(data): - print(f"โŒ Socket.IO connection error: {data}") - QMessageBox.warning(self, "Connection Error", - f"Failed to connect to server at {self.server_url}\n\nError: {data}") - - @self.socket.on('disconnect') - def on_disconnect(): - print("โš ๏ธ Socket.IO disconnected") - - self.socket.on('listener_count', self.on_listener_count) - + shutil.copy2(file_path, dest) + self.status_label.setText(f"Imported: {os.path.basename(file_path)}") + self.load_library() + except Exception as e: + QMessageBox.warning(self, "Import Error", f"Failed to import file: {e}") + else: + # Upload to server + try: + self.status_label.setText("Uploading to server...") + base_url = self.get_server_base_url() + + with open(file_path, 'rb') as f: + files = {'file': f} + response = requests.post(f"{base_url}/upload", files=files, timeout=60) + + if response.status_code == 200: + self.status_label.setText("Upload successful!") + self.load_library() + else: try: - self.socket.connect(self.server_url, wait_timeout=10) - print("โœ… Connection established") - except Exception as e: - print(f"โŒ Connection failed: {e}") - QMessageBox.critical(self, "Connection Failed", - f"Could not connect to {self.server_url}\n\nError: {str(e)}\n\nMake sure the server is running.") - return + err = response.json().get('error', 'Unknown error') + except: + err = f"Server returned {response.status_code}" + QMessageBox.warning(self, "Upload Error", f"Server error: {err}") + self.status_label.setText("Upload failed") + except Exception as e: + QMessageBox.warning(self, "Upload Error", f"Failed to upload: {e}") + self.status_label.setText("Upload error") + + def update_crossfade(self): + value = self.crossfader.value() + ratio = value / 100.0 + + # Cosine crossfade curve for smooth transition + deck_a_vol = int(math.cos(ratio * 0.5 * math.pi) * 100) + deck_b_vol = int(math.cos((1 - ratio) * 0.5 * math.pi) * 100) + + self.deck_a.set_xf_vol(deck_a_vol) + self.deck_b.set_xf_vol(deck_b_vol) + + def toggle_recording(self): + """Start or stop recording""" + if not self.is_recording: + # Start recording + from datetime import datetime + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"mix_{timestamp}.wav" + output_path = str(self.recordings_path / filename) + + if self.recording_worker.start_recording(output_path): + self.is_recording = True + self.recording_start_time = time.time() + self.recording_timer.start(1000) # Update every second - bitrate_map = {0: "128k", 1: "96k", 2: "64k", 3: "48k", 4: "32k"} - bitrate = bitrate_map.get(self.quality_combo.currentIndex(), "96k") - - print(f"๐Ÿ“ก Emitting start_broadcast with bitrate: {bitrate}") - self.socket.emit('start_broadcast', { - 'bitrate': bitrate, - 'format': 'mp3' - }) - - # Start local encoding thread - self.audio_engine.is_broadcasting = True - self.broadcast_thread = BroadcastThread(self.audio_engine.broadcast_queue, bitrate) - self.broadcast_thread.chunk_ready.connect(self.on_broadcast_chunk) - self.broadcast_thread.start() - - self.broadcasting = True - self.broadcast_btn.setText("๐ŸŸข STOP BROADCAST") - self.broadcast_btn.setStyleSheet(""" + # Update UI + self.record_button.setText("STOP") + self.record_button.setStyleSheet(""" QPushButton { - background: rgba(0, 255, 0, 0.2); - border: 2px solid #00ff00; - color: #00ff00; - font-family: 'Orbitron'; - font-size: 14px; + background-color: #550000; + color: #ff0000; + border: 2px solid #ff0000; font-weight: bold; - border-radius: 8px; + font-size: 14px; + padding: 8px; } QPushButton:hover { - background: rgba(0, 255, 0, 0.3); + background-color: #770000; + border-color: #ff3333; } """) - self.broadcast_status.setText("๐Ÿ”ด LIVE") - self.broadcast_status.setStyleSheet("color: #00ff00; font-size: 12px; font-weight: bold;") - - print("๐ŸŽ™๏ธ Broadcast started") - except Exception as e: - print(f"โŒ Broadcast error: {e}") - QMessageBox.warning(self, "Broadcast Error", f"Could not start broadcast:\n{e}") + self.recording_status_label.setText(f"Recording to: {filename}") + self.recording_status_label.setStyleSheet("color: #ff0000; font-size: 12px;") + print(f"[RECORDING] Started: {output_path}") else: - # Stop broadcast - if self.socket and self.socket.connected: - try: - self.socket.emit('stop_broadcast') - except Exception as e: - print(f"โŒ Failed to emit stop_broadcast: {e}") + # Stop recording + self.recording_worker.stop_recording() + self.is_recording = False + self.recording_timer.stop() - self.audio_engine.is_broadcasting = False - if self.broadcast_thread: - self.broadcast_thread.stop() - self.broadcast_thread = None - - self.broadcasting = False - self.broadcast_btn.setText("๐Ÿ”ด START BROADCAST") - self.broadcast_btn.setStyleSheet(""" + # Update UI + self.record_button.setText("REC") + self.record_button.setStyleSheet(""" QPushButton { - background: rgba(255, 0, 0, 0.2); - border: 2px solid #ff0000; - color: #ff0000; - font-family: 'Orbitron'; - font-size: 14px; + background-color: #330000; + color: #ff3333; + border: 2px solid #550000; font-weight: bold; - border-radius: 8px; + font-size: 14px; + padding: 8px; } QPushButton:hover { - background: rgba(255, 0, 0, 0.3); + background-color: #550000; + border-color: #ff0000; } """) - self.broadcast_status.setText("Offline") - self.broadcast_status.setStyleSheet("color: #888; font-size: 12px;") + self.recording_timer_label.setText("00:00") + self.recording_timer_label.setStyleSheet("color: #888; font-size: 24px; font-weight: bold; font-family: 'Courier New';") + self.recording_status_label.setText("Recording saved!") + self.recording_status_label.setStyleSheet("color: #00ff00; font-size: 12px;") + print("[RECORDING] Stopped") - print("๐Ÿ›‘ Broadcast stopped") + # Reset status after 3 seconds + QTimer.singleShot(3000, lambda: self.recording_status_label.setText("Ready to record") or self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;")) - def on_broadcast_chunk(self, chunk): - """Send encoded chunk to server via Socket.IO""" - if self.socket and self.socket.connected and self.broadcasting: - try: - self.socket.emit('audio_chunk', chunk) - except Exception as e: - print(f"โŒ Failed to send chunk: {e}") + def update_recording_time(self): + """Update the recording timer display""" + if self.is_recording: + elapsed = int(time.time() - self.recording_start_time) + minutes = elapsed // 60 + seconds = elapsed % 60 + self.recording_timer_label.setText(f"{minutes:02d}:{seconds:02d}") + self.recording_timer_label.setStyleSheet("color: #ff0000; font-size: 24px; font-weight: bold; font-family: 'Courier New';") + + def on_recording_error(self, error_msg): + """Handle recording errors""" + QMessageBox.critical(self, "Recording Error", error_msg) + if self.is_recording: + self.is_recording = False + self.recording_timer.stop() + self.record_button.setText("REC") + self.recording_status_label.setText("Recording failed") + self.recording_status_label.setStyleSheet("color: #ff0000; font-size: 12px;") + + def toggle_streaming(self): + """Toggle live streaming on/off""" + if not self.is_streaming: + # Get base URL from settings + base_url = self.get_server_base_url() + bitrate = self.all_settings.get("audio", {}).get("bitrate", 128) - def on_listener_count(self, data): - """Update listener count from server""" - self.listener_count = data.get('count', 0) - # Update UI if streaming panel is visible - if hasattr(self, 'listener_count_label'): - self.listener_count_label.setText(f"{self.listener_count}") - - def copy_stream_url(self): - """Copy stream URL to clipboard""" - clipboard = QApplication.clipboard() - clipboard.setText(self.stream_url.text()) - - # Show feedback - original_text = self.stream_url.text() - self.stream_url.setText("โœ… Copied!") - QTimer.singleShot(1000, lambda: self.stream_url.setText(original_text)) - - def toggle_glow(self, deck_id): - """Toggle glow effect for a deck""" - if deck_id == 'A': - self.glow_enabled['A'] = self.glow_a_check.isChecked() - else: - self.glow_enabled['B'] = self.glow_b_check.isChecked() - - print(f"โœจ Glow {deck_id}: {self.glow_enabled[deck_id]}") - - def update_glow_intensity(self, value): - """Update glow intensity""" - self.glow_intensity = value - - def update_glow_effect(self): - """Update window glow effect based on settings""" - # This would apply a glow effect to the window border - # For now, just update deck styling - for deck_id in ['A', 'B']: - if self.glow_enabled[deck_id]: - deck_widget = self.deck_a if deck_id == 'A' else self.deck_b - color = PRIMARY_CYAN if deck_id == 'A' else SECONDARY_MAGENTA - opacity = self.glow_intensity / 100.0 - - # Apply glow effect (simplified - could be enhanced with QGraphicsEffect) - deck_widget.setStyleSheet(deck_widget.styleSheet() + f""" - QWidget#deck {{ - box-shadow: 0 0 {self.glow_intensity}px rgba({color.red()}, {color.green()}, {color.blue()}, {opacity}); - }} + # Start streaming + if self.streaming_worker.start_streaming(base_url, bitrate): + self.is_streaming = True + self.stream_button.setText("STOP") + self.stream_button.setStyleSheet(""" + QPushButton { + background-color: #003366; + color: #00ff00; + border: 2px solid #0066cc; + font-weight: bold; + font-size: 14px; + padding: 8px; + } + QPushButton:hover { + background-color: #0066cc; + border-color: #00ff00; + } """) - - def start_download(self): - """Search or start direct download""" - query = self.dl_input.text().strip() - if not query: - return - - # Determine if it's a URL or search query - is_url = re.match(r'^https?://', query) - - if is_url: - self.perform_actual_download(query) + self.stream_status_label.setText("LIVE") + self.stream_status_label.setStyleSheet("color: #ff0000; font-size: 12px; font-weight: bold;") + print("[STREAMING] Started") else: - self.search_youtube(query) - - def search_youtube(self, query): - """Perform metadata search for youtube results""" - self.dl_input.setEnabled(False) - self.dl_btn.setEnabled(False) - self.dl_btn.setText("SEARCHING...") - - venv_path = os.path.join(os.path.dirname(__file__), ".venv/bin/yt-dlp") - yt_dlp_cmd = venv_path if os.path.exists(venv_path) else "yt-dlp" - - cmd = [ - yt_dlp_cmd, - f"ytsearch8:{query}", - "--print", "%(title)s ||| %(duration_string)s ||| %(webpage_url)s", - "--no-playlist", - "--flat-playlist" - ] - - print(f"๐Ÿ” Searching YouTube: {query}") - - self.search_process = QProcess() - self.search_process.finished.connect(self.on_search_finished) - self.search_process.start(cmd[0], cmd[1:]) - - def on_search_finished(self): - """Handle search results and show dialog""" - self.dl_input.setEnabled(True) - self.dl_btn.setEnabled(True) - self.dl_btn.setText("GET") - - # Check for errors - if self.search_process.exitCode() != 0: - err = str(self.search_process.readAllStandardError(), encoding='utf-8') - print(f"โŒ YouTube Search Error: {err}") - QMessageBox.warning(self, "Search Error", f"YouTube search failed:\n\n{err[:200]}...") - return - - output = str(self.search_process.readAllStandardOutput(), encoding='utf-8').strip() - if not output: - QMessageBox.warning(self, "No Results", "No YouTube results found for that query.") - return - - results = [r for r in output.split("\n") if " ||| " in r] - if not results: - QMessageBox.warning(self, "No Results", "Could not parse search results.") - return - - dialog = YouTubeSearchDialog(results, self) - dialog.item_selected.connect(self.perform_actual_download) - dialog.exec_() - - def perform_actual_download(self, url): - """Start the actual yt-dlp download process""" - # Use local folder or default to project's 'music' folder - dl_dir = self.local_folder if self.local_folder else "music" - if not os.path.exists(dl_dir): - os.makedirs(dl_dir, exist_ok=True) - - # Disable input during download - self.dl_input.setEnabled(False) - self.dl_btn.setEnabled(False) - self.dl_progress.setValue(0) - self.dl_progress.show() - - venv_path = os.path.join(os.path.dirname(__file__), ".venv/bin/yt-dlp") - yt_dlp_cmd = venv_path if os.path.exists(venv_path) else "yt-dlp" - - cmd = [ - yt_dlp_cmd, - "--extract-audio", - "--audio-format", "mp3", - "--audio-quality", "0", - "--output", f"{dl_dir}/%(title)s.%(ext)s", - "--no-playlist", - url - ] - - print(f"๐Ÿ“ฅ Starting download: {url}") - - self.dl_process = QProcess() - self.dl_process.readyReadStandardOutput.connect(self.on_dl_ready_read) - self.dl_process.finished.connect(self.on_dl_finished) - self.dl_process.start(cmd[0], cmd[1:]) - - def on_dl_ready_read(self): - """Parse yt-dlp output for progress""" - output = str(self.dl_process.readAllStandardOutput(), encoding='utf-8') - # Look for [download] 45.3% of 10.00MiB at 10.00MiB/s ETA 00:00 - match = re.search(r'\[download\]\s+(\d+\.\d+)%', output) - if match: - percent = float(match.group(1)) - self.dl_progress.setValue(int(percent)) - - def on_dl_finished(self): - """Handle download completion""" - self.dl_input.setEnabled(True) - self.dl_btn.setEnabled(True) - self.dl_progress.hide() - - if self.dl_process.exitCode() == 0: - print("โœ… Download finished successfully") - self.dl_input.clear() - self.fetch_library() # Refresh library to show new track - QMessageBox.information(self, "Download Complete", "Track downloaded and added to library!") - else: - err = str(self.dl_process.readAllStandardError(), encoding='utf-8') - if not err: - err = "Unknown error (check console)" - print(f"โŒ Download failed: {err}") - QMessageBox.warning(self, "Download Failed", f"Error: {err}") - - def upload_file(self): - """Upload MP3 file to server""" - file_path, _ = QFileDialog.getOpenFileName( - self, - "Upload MP3", - "", - "MP3 Files (*.mp3);;All Files (*)" - ) - - if file_path: - try: - filename = os.path.basename(file_path) - with open(file_path, 'rb') as f: - files = {'file': (filename, f, 'audio/mpeg')} - response = requests.post(f"{self.server_url}/upload", files=files) - - if response.json().get('success'): - print(f"โœ… Uploaded: {filename}") - QMessageBox.information(self, "Upload Success", f"Uploaded {filename}") - self.fetch_library() # Refresh library - else: - error = response.json().get('error', 'Unknown error') - QMessageBox.warning(self, "Upload Failed", error) - except Exception as e: - print(f"โŒ Upload error: {e}") - QMessageBox.warning(self, "Upload Error", str(e)) + # Stop streaming + self.streaming_worker.stop_streaming() + self.is_streaming = False + self.stream_button.setText("LIVE") + self.stream_button.setStyleSheet(""" + QPushButton { + background-color: #001a33; + color: #3399ff; + border: 2px solid #003366; + font-weight: bold; + font-size: 14px; + padding: 8px; + } + QPushButton:hover { + background-color: #003366; + border-color: #0066cc; + } + """) + self.stream_status_label.setText("Offline") + self.stream_status_label.setStyleSheet("color: #666; font-size: 12px;") + print("[STREAMING] Stopped") + def on_streaming_error(self, error_msg): + """Handle streaming errors""" + QMessageBox.critical(self, "Streaming Error", error_msg) + if self.is_streaming: + self.is_streaming = False + self.stream_button.setText("LIVE") + self.stream_status_label.setText("Error") + self.stream_status_label.setStyleSheet("color: #ff0000; font-size: 12px;") + + def update_listener_count(self, count): + self.listener_count_label.setText(f"{count} listeners") + + def get_server_base_url(self): + audio_settings = self.all_settings.get("audio", {}) + server_url = audio_settings.get("stream_server_url", "http://localhost:5000") + + # Normal techdj server runs on 5000 (DJ) and 5001 (Listener) + # If the URL is for the listener or stream, switch to 5000 + if ":5001" in server_url: + return server_url.split(":5001")[0] + ":5000" + elif ":8080" in server_url: + return server_url.split(":8080")[0] + ":5000" + elif "/api/stream" in server_url: + return server_url.split("/api/stream")[0].rstrip("/") + + if server_url.endswith("/"): server_url = server_url[:-1] + return server_url + def resizeEvent(self, event): - """Handle window resize to reposition floating elements""" + """Update glow frame size when window is resized""" super().resizeEvent(event) - - # Reposition floating buttons - if hasattr(self, 'streaming_btn'): - self.streaming_btn.move(self.width() - 70, self.height() - 280) - self.settings_btn.move(self.width() - 70, self.height() - 220) - self.upload_btn.move(self.width() - 70, self.height() - 160) - self.keyboard_btn.move(self.width() - 70, self.height() - 100) - - # Reposition panels - if hasattr(self, 'streaming_panel'): - self.streaming_panel.move(self.width() - 420, 20) - self.settings_panel.move(self.width() - 420, 20) + if hasattr(self, 'glow_frame'): + self.glow_frame.setGeometry(self.centralWidget().rect()) def closeEvent(self, event): - """Clean up resources before closing""" - # Stop broadcast if active - if self.broadcasting: - self.toggle_broadcast() + # Stop recording if active + if self.is_recording: + self.recording_worker.stop_recording() - # Disconnect Socket.IO - if self.socket and self.socket.connected: - try: - self.socket.disconnect() - print("๐Ÿ”Œ Socket.IO disconnected") - except Exception as e: - print(f"โš ๏ธ Error disconnecting Socket.IO: {e}") - - # Stop audio engine - self.audio_engine.stop_stream() - - # Wait for download threads to finish - for filename, thread in list(self.download_threads.items()): - if thread.isRunning(): - thread.wait(1000) # Wait up to 1 second + # Stop streaming if active + if self.is_streaming: + self.streaming_worker.stop_streaming() + self.deck_a.stop() + self.deck_b.stop() + self.search_worker.kill() + self.download_worker.kill() event.accept() - -def main(): +if __name__ == "__main__": app = QApplication(sys.argv) - app.setStyle('Fusion') - - # Set dark palette - palette = app.palette() - palette.setColor(palette.Window, BG_DARK) - palette.setColor(palette.WindowText, TEXT_MAIN) - palette.setColor(palette.Base, QColor(15, 15, 20)) - palette.setColor(palette.AlternateBase, QColor(20, 20, 30)) - palette.setColor(palette.Text, TEXT_MAIN) - palette.setColor(palette.Button, QColor(30, 30, 40)) - palette.setColor(palette.ButtonText, TEXT_M AIN) - app.setPalette(palette) - - window = TechDJMainWindow() - window.show() - \ No newline at end of file + app.setApplicationName("TechDJ Pro") + app.setDesktopFileName("techdj-pro") + window = DJApp() + window.show() + sys.exit(app.exec()) diff --git a/techdj_qt_v1.py.bak b/techdj_qt_v1.py.bak new file mode 100644 index 0000000..828a3cf --- /dev/null +++ b/techdj_qt_v1.py.bak @@ -0,0 +1,2801 @@ +#!/usr/bin/env python3 +""" +TechDJ - PyQt5 Native DJ Application +Pixel-perfect replica of the web DJ panel with neon aesthetic +""" + +import sys +import os +import json +import requests +import numpy as np +import sounddevice as sd +import soundfile as sf +from pathlib import Path +from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QSlider, QListWidget, QListWidgetItem, + QLineEdit, QFrame, QSplitter, QProgressBar, QMessageBox, + QDialog, QGridLayout, QCheckBox, QComboBox, QFileDialog) +from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, QRectF, QPropertyAnimation, QEasingCurve, QProcess, QSize +import re +from PyQt5.QtGui import (QPainter, QColor, QPen, QFont, QLinearGradient, + QRadialGradient, QBrush, QPainterPath, QFontDatabase, QIcon) +import socketio +import queue +import subprocess +import time +import threading +from scipy import signal + + +# Color constants matching web panel +BG_DARK = QColor(10, 10, 18) +PANEL_BG = QColor(20, 20, 30, 204) # 0.8 alpha +PRIMARY_CYAN = QColor(0, 243, 255) +SECONDARY_MAGENTA = QColor(188, 19, 254) +TEXT_MAIN = QColor(224, 224, 224) +TEXT_DIM = QColor(136, 136, 136) + + +class AudioEngine: + """Efficient local audio processing engine""" + + def __init__(self): + self.decks = { + 'A': { + 'audio_data': None, + 'sample_rate': 44100, + 'position': 0, + 'playing': False, + 'volume': 0.8, + 'speed': 1.0, + 'eq': {'low': 0, 'mid': 0, 'high': 0}, + 'filters': {'lowpass': 100, 'highpass': 0}, + 'duration': 0, + 'filename': None, + 'cues': {}, + 'loop_start': None, + 'loop_end': None, + 'loop_active': False, + 'repeat': False, + 'queue': [], + 'needs_next_track': False, + }, + 'B': { + 'audio_data': None, + 'sample_rate': 44100, + 'position': 0, + 'playing': False, + 'volume': 0.8, + 'speed': 1.0, + 'eq': {'low': 0, 'mid': 0, 'high': 0}, + 'filters': {'lowpass': 100, 'highpass': 0}, + 'duration': 0, + 'filename': None, + 'cues': {}, + 'loop_start': None, + 'loop_end': None, + 'loop_active': False, + 'repeat': False, + 'queue': [], + 'needs_next_track': False, + } + } + + self.crossfader = 0.5 + self.master_volume = 0.8 + self.stream = None + self.running = False + self.broadcast_queue = queue.Queue(maxsize=100) + self.is_broadcasting = False + self.lock = threading.Lock() + + # Filter states for each deck [deck_id][filter_name][channel] + self._filter_states = { + 'A': { + 'low': [np.zeros(2), np.zeros(2)], + 'mid': [np.zeros(2), np.zeros(2)], + 'high': [np.zeros(2), np.zeros(2)], + 'lp': [np.zeros(2), np.zeros(2)], + 'hp': [np.zeros(2), np.zeros(2)] + }, + 'B': { + 'low': [np.zeros(2), np.zeros(2)], + 'mid': [np.zeros(2), np.zeros(2)], + 'high': [np.zeros(2), np.zeros(2)], + 'lp': [np.zeros(2), np.zeros(2)], + 'hp': [np.zeros(2), np.zeros(2)] + } + } + + # Pre-calculated filter coefficients + self._filter_coeffs = {} + self._init_filters() + + # Pre-allocate reuse buffers for the audio thread + self._target_indices = np.arange(2048, dtype=np.float32) # Matches blocksize + + def _init_filters(self): + """Pre-calculate coefficients for standard bands""" + sr = 44100 + # Use standard pass filters for initialization + self._filter_coeffs['low'] = signal.butter(1, 300 / (sr/2), 'low') + self._filter_coeffs['mid'] = signal.butter(1, [400 / (sr/2), 3500 / (sr/2)], 'bandpass') + self._filter_coeffs['high'] = signal.butter(1, 4000 / (sr/2), 'high') + + def _apply_processing(self, deck_id, chunk): + """Apply EQ and Filters to the audio chunk""" + sr = 44100 + deck = self.decks[deck_id] + states = self._filter_states[deck_id] + + # 1. Apply EQ (Gain-based) + # We use a simple gain filter approximation for performance + low_gain = 10**(deck['eq']['low'] / 20.0) + mid_gain = 10**(deck['eq']['mid'] / 20.0) + high_gain = 10**(deck['eq']['high'] / 20.0) + + if low_gain != 1.0 or mid_gain != 1.0 or high_gain != 1.0: + # Simple gain scaling for demo; real biquads are better but more CPU intensive in Python + # For now, let's use a simple 3-band gain model + # Re-implementing as basic biquads for "Pro" feel + for ch in range(2): + # Low Shelf + b, a = signal.butter(1, 300/(sr/2), 'lowshelf') + # Adjust b for gain: b_gain = [b[0]*G, b[1]]? No, standard biquad gain is better + # But Scipy's butter doesn't take gain. We'll use a simpler approach for now: + # Multiply signal by gain factors for the specific bands. + pass + + # Simplified "Musical" EQ: + # We'll just apply the filters and sum them with gains + # This is more robust than chaining biquads for a high-level API + pass + + # Since proper IIR chaining is complex in a Python loop, we'll implement + # a high-performance resonance filter for LP/HP which is the most audible + + try: + # Low Pass Filter + lp_val = deck['filters']['lowpass'] # 0-100 + if lp_val < 100: + freq = max(50, 20000 * (lp_val / 100.0)**2) + b, a = signal.butter(1, freq / (sr/2), 'low') + for ch in range(2): + chunk[:, ch], states['lp'][ch] = signal.lfilter(b, a, chunk[:, ch], zi=states['lp'][ch]) + + # High Pass Filter + hp_val = deck['filters']['highpass'] # 0-100 + if hp_val > 0: + freq = max(20, 15000 * (hp_val / 100.0)**2) + b, a = signal.butter(1, freq / (sr/2), 'high') + for ch in range(2): + chunk[:, ch], states['hp'][ch] = signal.lfilter(b, a, chunk[:, ch], zi=states['hp'][ch]) + except Exception as e: + # Fallback if filter design fails due to extreme values + print(f"Filter processing error: {e}") + pass + + # EQ Gain (Simple multiplier for now to ensure sliders "do something") + combined_eq_gain = (low_gain + mid_gain + high_gain) / 3.0 + return chunk * combined_eq_gain + + def start_stream(self): + if self.stream is not None: + return + self.running = True + self.stream = sd.OutputStream( + channels=2, + samplerate=44100, + blocksize=2048, + callback=self._audio_callback + ) + self.stream.start() + print("๐ŸŽต Audio stream started") + + def stop_stream(self): + self.running = False + if self.stream: + self.stream.stop() + self.stream.close() + self.stream = None + + def _audio_callback(self, outdata, frames, time_info, status): + output = np.zeros((frames, 2), dtype=np.float32) + output_samplerate = 44100 + + with self.lock: + for deck_id in ['A', 'B']: + deck = self.decks[deck_id] + + if not deck['playing'] or deck['audio_data'] is None: + continue + + # Calculate source indices via linear interpolation + rate_ratio = deck['sample_rate'] / output_samplerate + step = rate_ratio * deck['speed'] + + # Start and end in source domain + src_start = deck['position'] + num_src_samples_needed = frames * step + src_end = src_start + num_src_samples_needed + + # Bounds check + if src_start >= len(deck['audio_data']) - 1: + deck['playing'] = False + continue + + # Prepare source data + # Ensure we don't read past the end + read_end = int(np.ceil(src_end)) + 1 + if read_end > len(deck['audio_data']): + read_end = len(deck['audio_data']) + + src_chunk = deck['audio_data'][int(src_start):read_end] + + if len(src_chunk) < 2: + deck['playing'] = False + continue + + if src_chunk.ndim == 1: + src_chunk = np.column_stack((src_chunk, src_chunk)) + + # Time indices for interpolation + if len(self._target_indices) != frames: + self._target_indices = np.arange(frames, dtype=np.float32) + + x_target = self._target_indices * step + x_source = np.arange(len(src_chunk)) + + # Interp each channel + try: + resampled_l = np.interp(x_target, x_source, src_chunk[:, 0]) + resampled_r = np.interp(x_target, x_source, src_chunk[:, 1]) + chunk = np.column_stack((resampled_l, resampled_r)) + + # Apply processing (EQ and Filters) + chunk = self._apply_processing(deck_id, chunk) + + chunk = chunk * deck['volume'] + + if deck_id == 'A': + chunk = chunk * (1.0 - self.crossfader) + else: + chunk = chunk * self.crossfader + + output += chunk + + # Update position + deck['position'] += num_src_samples_needed + except Exception as e: + print(f"Audio thread error in interp: {e}") + deck['playing'] = False + continue + + # Handle looping + if deck['loop_active'] and deck['loop_start'] is not None and deck['loop_end'] is not None: + loop_start_frame = deck['loop_start'] * deck['sample_rate'] + loop_end_frame = deck['loop_end'] * deck['sample_rate'] + + if deck['position'] >= loop_end_frame: + deck['position'] = loop_start_frame + (deck['position'] - loop_end_frame) + + # Auto-stop at end + if deck['position'] >= len(deck['audio_data']): + if deck['repeat']: + # Loop current track + deck['position'] = 0 + elif len(deck['queue']) > 0: + # Mark that we need to load next track + # Can't load here (wrong thread), UI will handle it + deck['playing'] = False + deck['needs_next_track'] = True + else: + deck['playing'] = False + + output = output * self.master_volume + outdata[:] = output + + # Capture for broadcast + if self.is_broadcasting: + try: + self.broadcast_queue.put_nowait(output.tobytes()) + except queue.Full: + pass + + def load_track(self, deck_id, filepath): + try: + audio_data, sample_rate = sf.read(filepath, dtype='float32') + with self.lock: + self.decks[deck_id]['audio_data'] = audio_data + self.decks[deck_id]['sample_rate'] = sample_rate + self.decks[deck_id]['position'] = 0 + self.decks[deck_id]['duration'] = len(audio_data) / sample_rate + self.decks[deck_id]['filename'] = os.path.basename(filepath) + print(f"โœ… Loaded {os.path.basename(filepath)} to Deck {deck_id}") + return True + except Exception as e: + print(f"โŒ Error loading {filepath}: {e}") + return False + + def play(self, deck_id): + with self.lock: + if self.decks[deck_id]['audio_data'] is not None: + self.decks[deck_id]['playing'] = True + + def pause(self, deck_id): + with self.lock: + self.decks[deck_id]['playing'] = False + + def seek(self, deck_id, position_seconds): + with self.lock: + deck = self.decks[deck_id] + if deck['audio_data'] is not None: + deck['position'] = int(position_seconds * deck['sample_rate']) + + def set_volume(self, deck_id, volume): + with self.lock: + self.decks[deck_id]['volume'] = max(0.0, min(1.0, volume)) + + def set_speed(self, deck_id, speed): + with self.lock: + self.decks[deck_id]['speed'] = max(0.5, min(1.5, speed)) + + def set_crossfader(self, value): + with self.lock: + self.crossfader = max(0.0, min(1.0, value)) + + def get_position(self, deck_id): + with self.lock: + deck = self.decks[deck_id] + if deck['audio_data'] is not None: + return deck['position'] / deck['sample_rate'] + return 0.0 + + def set_cue(self, deck_id, cue_num): + position = self.get_position(deck_id) + with self.lock: + self.decks[deck_id]['cues'][cue_num] = position + + def jump_to_cue(self, deck_id, cue_num): + with self.lock: + if cue_num in self.decks[deck_id]['cues']: + position = self.decks[deck_id]['cues'][cue_num] + self.seek(deck_id, position) + + def set_eq(self, deck_id, band, value): + with self.lock: + self.decks[deck_id]['eq'][band] = value + + def set_filter(self, deck_id, filter_type, value): + with self.lock: + self.decks[deck_id]['filters'][filter_type] = value + + def set_repeat(self, deck_id, enabled): + """Toggle repeat/loop for a deck""" + with self.lock: + self.decks[deck_id]['repeat'] = enabled + + def set_loop_in(self, deck_id): + position = self.get_position(deck_id) + with self.lock: + self.decks[deck_id]['loop_start'] = position + # If we already have an end, activate loop + if self.decks[deck_id]['loop_end'] is not None: + self.decks[deck_id]['loop_active'] = True + + def set_loop_out(self, deck_id): + position = self.get_position(deck_id) + with self.lock: + self.decks[deck_id]['loop_end'] = position + # If we already have a start, activate loop + if self.decks[deck_id]['loop_start'] is not None: + self.decks[deck_id]['loop_active'] = True + + def exit_loop(self, deck_id): + with self.lock: + self.decks[deck_id]['loop_active'] = False + self.decks[deck_id]['loop_start'] = None + self.decks[deck_id]['loop_end'] = None + + def add_to_queue(self, deck_id, filepath): + """Add track to deck's queue""" + with self.lock: + self.decks[deck_id]['queue'].append(filepath) + + def remove_from_queue(self, deck_id, index): + """Remove track from queue by index""" + with self.lock: + if 0 <= index < len(self.decks[deck_id]['queue']): + self.decks[deck_id]['queue'].pop(index) + + def clear_queue(self, deck_id): + """Clear all tracks from queue""" + with self.lock: + self.decks[deck_id]['queue'].clear() + + def get_queue(self, deck_id): + """Get current queue (returns a copy)""" + with self.lock: + return list(self.decks[deck_id]['queue']) + + def pop_next_from_queue(self, deck_id): + """Get and remove next track from queue""" + with self.lock: + if len(self.decks[deck_id]['queue']) > 0: + return self.decks[deck_id]['queue'].pop(0) + return None + + +class DownloadThread(QThread): + progress = pyqtSignal(int) + finished = pyqtSignal(str, bool) + + def __init__(self, url, filepath): + super().__init__() + self.url = url + self.filepath = filepath + + def run(self): + try: + print(f"๐Ÿ“ฅ Downloading from: {self.url}") + response = requests.get(self.url, stream=True, timeout=30) + + # Check if request was successful + if response.status_code != 200: + print(f"โŒ HTTP {response.status_code}: {self.url}") + self.finished.emit(self.filepath, False) + return + + total_size = int(response.headers.get('content-length', 0)) + print(f"๐Ÿ“ฆ File size: {total_size / 1024 / 1024:.2f} MB") + + os.makedirs(os.path.dirname(self.filepath), exist_ok=True) + + downloaded = 0 + with open(self.filepath, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) + if total_size > 0: + progress = int((downloaded / total_size) * 100) + self.progress.emit(progress) + + print(f"โœ… Download complete: {os.path.basename(self.filepath)}") + self.finished.emit(self.filepath, True) + except requests.exceptions.Timeout: + print(f"โŒ Download timeout: {self.url}") + self.finished.emit(self.filepath, False) + except requests.exceptions.ConnectionError as e: + print(f"โŒ Connection error: {e}") + self.finished.emit(self.filepath, False) + except Exception as e: + print(f"โŒ Download error: {type(e).__name__}: {e}") + self.finished.emit(self.filepath, False) + + +class BroadcastThread(QThread): + """Thread to handle FFmpeg encoding and streaming""" + chunk_ready = pyqtSignal(bytes) + error = pyqtSignal(str) + + def __init__(self, audio_queue, bitrate="192k"): + super().__init__() + self.audio_queue = audio_queue + self.bitrate = bitrate + self.running = False + self.process = None + + def run(self): + self.running = True + + # FFmpeg command to read raw f32le PCM and output MP3 chunks to stdout + # Using CBR and zerolatency tune for stability + cmd = [ + 'ffmpeg', + '-y', + '-fflags', 'nobuffer', + '-flags', 'low_delay', + '-probesize', '32', + '-analyzeduration', '0', + '-f', 'f32le', + '-ar', '44100', + '-ac', '2', + '-i', 'pipe:0', + '-codec:a', 'libmp3lame', + '-b:a', self.bitrate, + '-maxrate', self.bitrate, + '-minrate', self.bitrate, + '-bufsize', '64k', + '-tune', 'zerolatency', + '-flush_packets', '1', + '-f', 'mp3', + 'pipe:1' + ] + + try: + self.process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0 + ) + + # Thread to read encoded chunks from stdout + def read_output(): + # Smaller buffer for more frequent updates (2KB = ~0.08s @ 192k) + buffer_size = 2048 + while self.running: + try: + data = self.process.stdout.read(buffer_size) + if data: + self.chunk_ready.emit(data) + else: + break + except Exception as e: + print(f"Broadcast output error: {e}") + break + + output_thread = threading.Thread(target=read_output, daemon=True) + output_thread.start() + + print(f"๐Ÿ“ก FFmpeg broadcast process started ({self.bitrate})") + + # Worker to feed stdin from the broadcast queue + while self.running: + try: + # Clear queue if it's way too full, but be less aggressive + # 100 chunks is ~4.6 seconds. If we hit 200, we're definitely lagging. + if self.audio_queue.qsize() > 200: + while self.audio_queue.qsize() > 50: + self.audio_queue.get_nowait() + + chunk = self.audio_queue.get(timeout=0.1) + if chunk and self.process and self.process.stdin: + self.process.stdin.write(chunk) + self.process.stdin.flush() + except queue.Empty: + continue + except Exception as e: + print(f"Broadcast input error: {e}") + break + + except Exception as e: + self.error.emit(str(e)) + self.running = False + return + + def stop(self): + self.running = False + if self.process: + self.process.terminate() + try: + self.process.wait(timeout=2) + except: + self.process.kill() + self.process = None + # Give output thread time to finish + time.sleep(0.1) + print("๐Ÿ›‘ Broadcast process stopped") + + +class WaveformWidget(QWidget): + """Waveform display matching web panel style""" + + def __init__(self, deck_id, parent=None): + super().__init__(parent) + self.deck_id = deck_id + self.waveform_data = [] + self.position = 0.0 + self.duration = 1.0 + self.cues = {} + self.setFixedHeight(180) # Pro-visual height + self.setStyleSheet("background: #000; border: none;") # Removed internal border + + def set_waveform(self, audio_data, sample_rate): + if audio_data is None: + self.waveform_data = [] + return + + samples = 2000 # Increased resolution + if audio_data.ndim > 1: + audio_data = np.mean(audio_data, axis=1) + + # Normalize globally for better visualization + max_val = np.max(np.abs(audio_data)) + if max_val > 0: + audio_data = audio_data / max_val + + block_size = max(1, len(audio_data) // samples) + self.waveform_data = [] + + for i in range(samples): + start = i * block_size + end = min(start + block_size, len(audio_data)) + if start < len(audio_data): + chunk = audio_data[start:end] + # Store both max and min for a more detailed mirror wave + self.waveform_data.append((np.max(chunk), np.min(chunk))) + + self.update() + + def set_position(self, position, duration): + # Only update if position changed significantly (reduces repaints) + if abs(position - self.position) > 0.1 or duration != self.duration: + self.position = position + self.duration = max(duration, 0.01) + self.update() + + def set_cues(self, cues): + self.cues = cues + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # Background + painter.fillRect(self.rect(), QColor(0, 0, 0)) + + if not self.waveform_data: + return + + # Draw waveform + width = self.width() + height = self.height() + bar_width = width / len(self.waveform_data) + + wave_color = PRIMARY_CYAN if self.deck_id == 'A' else SECONDARY_MAGENTA + painter.setPen(Qt.NoPen) + + # Create semi-transparent brush for visual depth + brush_color = QColor(wave_color) + brush_color.setAlpha(180) + painter.setBrush(QBrush(brush_color)) + + for i, (peak, val) in enumerate(self.waveform_data): + x = i * bar_width + + # Use almost full height (0.95) to make it look "tall" as requested + # 'peak' and 'val' are normalized -1 to 1 + pos_height = peak * (height / 2) * 0.95 + neg_height = abs(val) * (height / 2) * 0.95 + + # Top half + painter.drawRect(int(x), int(height/2 - pos_height), max(1, int(bar_width)), int(pos_height)) + # Bottom half + painter.drawRect(int(x), int(height/2), max(1, int(bar_width)), int(neg_height)) + + # Draw cue markers + if self.duration > 0: + painter.setPen(QPen(QColor(255, 255, 255), 1)) + for cue_time in self.cues.values(): + x = (cue_time / self.duration) * width + painter.drawLine(int(x), 0, int(x), height) + + # Draw playhead + if self.duration > 0: + playhead_x = (self.position / self.duration) * width + painter.setPen(QPen(QColor(255, 255, 0), 2)) + painter.drawLine(int(playhead_x), 0, int(playhead_x), height) + + def mousePressEvent(self, event): + """Allow seeking by clicking on waveform""" + if self.duration > 0: + percent = event.x() / self.width() + seek_time = percent * self.duration + self.parent().parent().seek_deck(seek_time) + + +class VinylDiskWidget(QWidget): + """Animated vinyl disk matching web panel""" + + clicked = pyqtSignal() + + def __init__(self, deck_id, parent=None): + super().__init__(parent) + self.deck_id = deck_id + self.rotation = 0 + self.playing = False + self.setFixedSize(120, 120) + + # Rotation animation + self.timer = QTimer() + self.timer.timeout.connect(self.rotate) + + def set_playing(self, playing): + self.playing = playing + if playing: + self.timer.start(100) # 10 FPS - reduced for better performance + else: + self.timer.stop() + self.update() + + def set_speed(self, speed): + self.speed = speed + + def rotate(self): + # Base rotation is 5 degrees, scaled by playback speed + speed_factor = getattr(self, 'speed', 1.0) + self.rotation = (self.rotation + (5 * speed_factor)) % 360 + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + center_x = self.width() / 2 + center_y = self.height() / 2 + radius = min(center_x, center_y) - 5 + + # Rotate if playing + if self.playing: + painter.translate(center_x, center_y) + painter.rotate(self.rotation) + painter.translate(-center_x, -center_y) + + # Vinyl gradient + gradient = QRadialGradient(center_x, center_y, radius) + gradient.setColorAt(0, QColor(34, 34, 34)) + gradient.setColorAt(0.1, QColor(17, 17, 17)) + gradient.setColorAt(1, QColor(0, 0, 0)) + + painter.setBrush(gradient) + painter.setPen(QPen(QColor(51, 51, 51), 2)) + painter.drawEllipse(int(center_x - radius), int(center_y - radius), + int(radius * 2), int(radius * 2)) + + # Grooves + painter.setPen(QPen(QColor(24, 24, 24), 1)) + for i in range(5, int(radius), 8): + painter.drawEllipse(int(center_x - i), int(center_y - i), i * 2, i * 2) + + # Center label + label_radius = 25 + label_color = PRIMARY_CYAN if self.deck_id == 'A' else SECONDARY_MAGENTA + painter.setBrush(label_color) + painter.setPen(QPen(label_color.darker(120), 2)) + painter.drawEllipse(int(center_x - label_radius), int(center_y - label_radius), + label_radius * 2, label_radius * 2) + + # Label text + painter.setPen(QColor(0, 0, 0)) + font = QFont("Orbitron", 16, QFont.Bold) + painter.setFont(font) + painter.drawText(self.rect(), Qt.AlignCenter, self.deck_id) + + # Glow effect when playing + if self.playing: + painter.setPen(QPen(label_color, 3)) + painter.setBrush(Qt.NoBrush) + painter.drawEllipse(int(center_x - radius - 3), int(center_y - radius - 3), + int((radius + 3) * 2), int((radius + 3) * 2)) + + def mousePressEvent(self, event): + self.clicked.emit() + + +class NeonButton(QPushButton): + """Neon-styled button matching web panel""" + + def __init__(self, text, color=PRIMARY_CYAN, parent=None): + super().__init__(text, parent) + self.neon_color = color + self.is_active = False + self.update_style() + + def set_active(self, active): + self.is_active = active + self.update_style() + + def update_style(self): + if self.is_active: + self.setStyleSheet(f""" + QPushButton {{ + background: rgba({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}, 0.3); + border: 2px solid rgb({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}); + color: rgb({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}); + font-family: 'Orbitron'; + font-weight: bold; + padding: 8px; + border-radius: 4px; + }} + QPushButton:hover {{ + background: rgba({self.neon_color.red()}, {self.neon_color.green()}, {self.neon_color.blue()}, 0.5); + }} + """) + else: + self.setStyleSheet(f""" + QPushButton {{ + background: #222; + border: 1px solid #444; + color: #666; + font-family: 'Orbitron'; + font-weight: bold; + padding: 8px; + border-radius: 4px; + }} + QPushButton:hover {{ + background: #333; + color: #888; + }} + """) + + +class DeckWidget(QWidget): + """Complete deck widget matching web panel layout""" + + def __init__(self, deck_id, audio_engine, parent=None): + super().__init__(parent) + self.deck_id = deck_id + self.audio_engine = audio_engine + self.color = PRIMARY_CYAN if deck_id == 'A' else SECONDARY_MAGENTA + + self.init_ui() + + # Update timer + self.timer = QTimer() + self.timer.timeout.connect(self.update_display) + self.timer.start(100) # 10 FPS - reduced for better performance + + def init_ui(self): + layout = QVBoxLayout() + layout.setSpacing(5) # Reduced from 8 + layout.setContentsMargins(10, 8, 10, 10) # Reduced top margin + + # Headers removed as requested + + # Waveform + waveform_container = QWidget() + waveform_container.setFixedHeight(184) # 180px graph + 4px padding + waveform_container.setStyleSheet("background: #111; border: 1px solid #333; border-radius: 4px;") + waveform_layout = QVBoxLayout(waveform_container) + waveform_layout.setContentsMargins(2, 2, 2, 2) + + self.waveform = WaveformWidget(self.deck_id, self) + waveform_layout.addWidget(self.waveform) + + # Subtle Metadata Overlay (Integrated into Graph Box) + meta_layout = QHBoxLayout() + meta_layout.setContentsMargins(4, 0, 4, 1) + + self.deck_id_label = QLabel(f"[{self.deck_id}]") + self.deck_id_label.setStyleSheet(f"color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); font-family: 'Orbitron'; font-size: 9px; font-weight: bold;") + meta_layout.addWidget(self.deck_id_label) + + self.track_label = QLabel("EMPTY") + self.track_label.setStyleSheet("color: #bbb; font-family: 'Rajdhani'; font-size: 9px; font-weight: bold;") + meta_layout.addWidget(self.track_label, 1) + + self.time_label = QLabel("0:00 / 0:00") + self.time_label.setStyleSheet("color: #888; font-family: 'Orbitron'; font-size: 8px;") + meta_layout.addWidget(self.time_label) + + waveform_layout.addLayout(meta_layout) + + layout.addWidget(waveform_container) + + # Restoring the nice DJ circles + disk_container = QHBoxLayout() + disk_container.addStretch() + self.vinyl_disk = VinylDiskWidget(self.deck_id) + self.vinyl_disk.clicked.connect(self.toggle_play) + disk_container.addWidget(self.vinyl_disk) + disk_container.addStretch() + layout.addLayout(disk_container) + + # Hot Cues + cue_layout = QGridLayout() + cue_layout.setSpacing(3) + self.cue_buttons = [] + for i in range(4): + btn = NeonButton(f"CUE {i+1}", self.color) + btn.clicked.connect(lambda checked, num=i+1: self.handle_cue(num)) + cue_layout.addWidget(btn, 0, i) + self.cue_buttons.append(btn) + layout.addLayout(cue_layout) + + # Loop Controls + loop_layout = QGridLayout() + loop_layout.setSpacing(3) + loop_in = NeonButton("LOOP IN", QColor(255, 102, 0)) + loop_in.clicked.connect(lambda: self.audio_engine.set_loop_in(self.deck_id)) + + loop_out = NeonButton("LOOP OUT", QColor(255, 102, 0)) + loop_out.clicked.connect(lambda: self.audio_engine.set_loop_out(self.deck_id)) + + loop_exit = NeonButton("EXIT", QColor(255, 102, 0)) + loop_exit.clicked.connect(lambda: self.audio_engine.exit_loop(self.deck_id)) + + loop_layout.addWidget(loop_in, 0, 0) + loop_layout.addWidget(loop_out, 0, 1) + loop_layout.addWidget(loop_exit, 0, 2) + layout.addLayout(loop_layout) + + # Controls Grid + controls = QGridLayout() + controls.setSpacing(8) + + # Volume + vol_label = QLabel("VOLUME") + vol_label.setStyleSheet("color: #888; font-size: 10px;") + controls.addWidget(vol_label, 0, 0) + self.volume_slider = QSlider(Qt.Horizontal) + self.volume_slider.setRange(0, 100) + self.volume_slider.setValue(80) + self.volume_slider.valueChanged.connect(self.on_volume_change) + self.volume_slider.setStyleSheet(self.get_slider_style()) + controls.addWidget(self.volume_slider, 1, 0) + + # EQ + eq_widget = QWidget() + eq_layout = QHBoxLayout(eq_widget) + eq_layout.setSpacing(8) + self.eq_sliders = {} + + for band in ['HIGH', 'MID', 'LOW']: + band_widget = QWidget() + band_layout = QVBoxLayout(band_widget) + band_layout.setSpacing(2) + band_layout.setContentsMargins(0, 0, 0, 0) + + slider = QSlider(Qt.Vertical) + slider.setRange(-20, 20) + slider.setValue(0) + slider.setFixedHeight(80) + slider.setStyleSheet(self.get_slider_style()) + slider.valueChanged.connect(lambda v, b=band.lower(): self.on_eq_change(b, v)) + self.eq_sliders[band.lower()] = slider + + label = QLabel(band) + label.setStyleSheet("color: #888; font-size: 9px;") + label.setAlignment(Qt.AlignCenter) + + band_layout.addWidget(slider) + band_layout.addWidget(label) + eq_layout.addWidget(band_widget) + + controls.addWidget(eq_widget, 0, 1, 2, 1) + + # Filters + filter_widget = QWidget() + filter_layout = QVBoxLayout(filter_widget) + filter_layout.setSpacing(4) + + lp_label = QLabel("LOW-PASS") + lp_label.setStyleSheet("color: #888; font-size: 9px;") + filter_layout.addWidget(lp_label) + self.lp_slider = QSlider(Qt.Horizontal) + self.lp_slider.setRange(0, 100) + self.lp_slider.setValue(100) + self.lp_slider.setStyleSheet(self.get_slider_style()) + self.lp_slider.valueChanged.connect(lambda v: self.audio_engine.set_filter(self.deck_id, 'lowpass', v)) + filter_layout.addWidget(self.lp_slider) + + hp_label = QLabel("HIGH-PASS") + hp_label.setStyleSheet("color: #888; font-size: 9px;") + filter_layout.addWidget(hp_label) + self.hp_slider = QSlider(Qt.Horizontal) + self.hp_slider.setRange(0, 100) + self.hp_slider.setValue(0) + self.hp_slider.setStyleSheet(self.get_slider_style()) + self.hp_slider.valueChanged.connect(lambda v: self.audio_engine.set_filter(self.deck_id, 'highpass', v)) + filter_layout.addWidget(self.hp_slider) + + controls.addWidget(filter_widget, 0, 2, 2, 1) + + # Speed + speed_widget = QWidget() + speed_layout = QVBoxLayout(speed_widget) + speed_layout.setSpacing(4) + + speed_label = QLabel("PITCH / TEMPO") + speed_label.setStyleSheet("color: #888; font-size: 9px;") + speed_layout.addWidget(speed_label) + + self.speed_slider = QSlider(Qt.Horizontal) + self.speed_slider.setRange(50, 150) + self.speed_slider.setValue(100) + self.speed_slider.valueChanged.connect(self.on_speed_change) + self.speed_slider.setStyleSheet(self.get_slider_style()) + speed_layout.addWidget(self.speed_slider) + + bend_layout = QHBoxLayout() + bend_minus = QPushButton("-") + bend_minus.setFixedSize(30, 25) + bend_minus.pressed.connect(lambda: self.on_pitch_bend(-0.02)) + bend_minus.released.connect(lambda: self.on_pitch_bend(0)) + + bend_plus = QPushButton("+") + bend_plus.setFixedSize(30, 25) + bend_plus.pressed.connect(lambda: self.on_pitch_bend(0.02)) + bend_plus.released.connect(lambda: self.on_pitch_bend(0)) + + bend_layout.addWidget(bend_minus) + bend_layout.addWidget(bend_plus) + speed_layout.addLayout(bend_layout) + + controls.addWidget(speed_widget, 0, 3, 2, 1) + + layout.addLayout(controls) + + # Transport + transport = QHBoxLayout() + transport.setSpacing(4) + + self.play_btn = NeonButton("โ–ถ PLAY", self.color) + self.play_btn.clicked.connect(self.play) + transport.addWidget(self.play_btn) + + self.pause_btn = NeonButton("โธ PAUSE") + self.pause_btn.clicked.connect(self.pause) + transport.addWidget(self.pause_btn) + + sync_btn = NeonButton("SYNC", self.color) + sync_btn.clicked.connect(self.on_sync) + transport.addWidget(sync_btn) + + reset_btn = NeonButton("๐Ÿ”„ RESET") + reset_btn.clicked.connect(self.reset_deck) + transport.addWidget(reset_btn) + + self.loop_btn = NeonButton("๐Ÿ” LOOP") + self.loop_btn.setCheckable(True) + self.loop_btn.clicked.connect(self.toggle_loop) + transport.addWidget(self.loop_btn) + + layout.addLayout(transport) + + # Queue List + queue_container = QWidget() + queue_container.setStyleSheet("background: rgba(0, 0, 0, 0.4); border-top: 1px solid #333;") + queue_layout = QVBoxLayout(queue_container) + queue_layout.setContentsMargins(5, 5, 5, 5) + queue_layout.setSpacing(2) + + queue_label = QLabel("NEXT UP / QUEUE") + queue_label.setStyleSheet(f"color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); font-family: 'Orbitron'; font-size: 9px; font-weight: bold;") + queue_layout.addWidget(queue_label) + + self.queue_list = QListWidget() + self.queue_list.setFixedHeight(80) + self.queue_list.setStyleSheet(""" + QListWidget { + background: transparent; + border: none; + color: #aaa; + font-family: 'Rajdhani'; + font-size: 10px; + } + QListWidget::item { + padding: 2px; + border-bottom: 1px solid #222; + } + """) + queue_layout.addWidget(self.queue_list) + layout.addWidget(queue_container) + + layout.addStretch() # Push everything up + + self.setLayout(layout) + + # Deck styling + self.setStyleSheet(f""" + QWidget {{ + background: rgba(20, 20, 30, 0.8); + color: #e0e0e0; + font-family: 'Rajdhani'; + }} + QWidget#deck {{ + border: 2px solid rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); + border-radius: 8px; + }} + """) + self.setObjectName("deck") + + def get_slider_style(self): + return """ + QSlider::groove:horizontal { + height: 8px; + background: #333; + border-radius: 4px; + } + QSlider::handle:horizontal { + background: #ccc; + border: 2px solid #888; + width: 16px; + margin: -4px 0; + border-radius: 8px; + } + QSlider::groove:vertical { + width: 8px; + background: #333; + border-radius: 4px; + } + QSlider::handle:vertical { + background: #ccc; + border: 2px solid #888; + height: 16px; + margin: 0 -4px; + border-radius: 8px; + } + """ + + def load_track(self, filepath): + if self.audio_engine.load_track(self.deck_id, filepath): + filename = os.path.basename(filepath) + self.track_label.setText(filename.upper()) + deck = self.audio_engine.decks[self.deck_id] + self.waveform.set_waveform(deck['audio_data'], deck['sample_rate']) + + def play(self): + self.audio_engine.play(self.deck_id) + self.vinyl_disk.set_playing(True) + self.play_btn.set_active(True) + + def pause(self): + self.audio_engine.pause(self.deck_id) + self.vinyl_disk.set_playing(False) + self.play_btn.set_active(False) + + def toggle_play(self): + if self.audio_engine.decks[self.deck_id]['playing']: + self.pause() + else: + self.play() + + def on_volume_change(self, value): + self.audio_engine.set_volume(self.deck_id, value / 100.0) + + def on_speed_change(self, value): + self.audio_engine.set_speed(self.deck_id, value / 100.0) + + def on_eq_change(self, band, value): + self.audio_engine.set_eq(self.deck_id, band, value) + + def on_sync(self): + """Match speed to other deck""" + other_deck_id = 'B' if self.deck_id == 'A' else 'A' + other_speed = self.audio_engine.decks[other_deck_id]['speed'] + self.speed_slider.setValue(int(other_speed * 100)) + print(f"๐ŸŽต Deck {self.deck_id} synced to {other_speed:.2f}x") + + def on_pitch_bend(self, amount): + """Temporarily adjust speed for nudging""" + base_speed = self.speed_slider.value() / 100.0 + self.audio_engine.set_speed(self.deck_id, base_speed + amount) + + def handle_cue(self, cue_num): + deck = self.audio_engine.decks[self.deck_id] + if cue_num in deck['cues']: + self.audio_engine.jump_to_cue(self.deck_id, cue_num) + else: + self.audio_engine.set_cue(self.deck_id, cue_num) + self.cue_buttons[cue_num-1].set_active(True) + + def seek_deck(self, time): + self.audio_engine.seek(self.deck_id, time) + + def reset_deck(self): + """Reset all deck controls to default values""" + # Setting values on sliders will trigger the valueChanged signal + # which will in turn update the audio engine. + + # Reset volume to 80% + self.volume_slider.setValue(80) + + # Reset speed to 100% + self.speed_slider.setValue(100) + + # Reset EQ sliders to 0 + if hasattr(self, 'eq_sliders'): + for band, slider in self.eq_sliders.items(): + slider.setValue(0) + + # Reset filter sliders + self.lp_slider.setValue(100) + self.hp_slider.setValue(0) + + print(f"๐Ÿ”„ Deck {self.deck_id} reset to defaults") + + def toggle_loop(self): + """Toggle loop/repeat for this deck""" + is_looping = self.loop_btn.isChecked() + self.audio_engine.set_repeat(self.deck_id, is_looping) + + if is_looping: + self.loop_btn.setStyleSheet(f""" + QPushButton {{ + background: rgba({self.color.red()}, {self.color.green()}, {self.color.blue()}, 0.3); + border: 2px solid rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); + color: rgb({self.color.red()}, {self.color.green()}, {self.color.blue()}); + font-family: 'Orbitron'; + font-size: 12px; + font-weight: bold; + border-radius: 6px; + }} + """) + print(f"๐Ÿ” Deck {self.deck_id} loop enabled") + else: + self.loop_btn.setStyleSheet(""" + QPushButton { + background: rgba(0, 0, 0, 0.3); + border: 2px solid #666; + color: #888; + font-family: 'Orbitron'; + font-size: 12px; + border-radius: 6px; + } + """) + print(f"โน๏ธ Deck {self.deck_id} loop disabled") + + def update_display(self): + deck = self.audio_engine.decks[self.deck_id] + position = self.audio_engine.get_position(self.deck_id) + duration = deck['duration'] + + # Check if we need to load next track from queue + if deck.get('needs_next_track', False): + deck['needs_next_track'] = False + next_track = self.audio_engine.pop_next_from_queue(self.deck_id) + if next_track: + print(f"๐Ÿ“‹ Auto-loading next track from queue: {os.path.basename(next_track)}") + self.load_track(next_track) + self.play() + + # Time calculations + pos_min = int(position // 60) + pos_sec = int(position % 60) + dur_min = int(duration // 60) + dur_sec = int(duration % 60) + self.time_label.setText(f"{pos_min}:{pos_sec:02d} / {dur_min}:{dur_sec:02d}") + + self.waveform.set_position(position, duration) + self.waveform.set_cues(deck['cues']) + self.vinyl_disk.set_speed(deck['speed']) + + # Update Queue Display (only when changed) + current_queue = deck.get('queue', []) + # Check if queue actually changed (not just count) + queue_changed = False + if self.queue_list.count() != len(current_queue): + queue_changed = True + else: + # Check if items are different + for i, track_path in enumerate(current_queue): + if i >= self.queue_list.count() or self.queue_list.item(i).text() != os.path.basename(track_path): + queue_changed = True + break + + if queue_changed: + self.queue_list.clear() + for track_path in current_queue: + filename = os.path.basename(track_path) + self.queue_list.addItem(filename) + +class YouTubeSearchDialog(QDialog): + """Dialog to display and select YouTube search results""" + item_selected = pyqtSignal(str) # Emits the URL + + def __init__(self, results, parent=None): + super().__init__(parent) + self.setWindowTitle("YouTube Search Results") + self.setFixedWidth(600) + self.setFixedHeight(400) + self.setStyleSheet(f""" + QDialog {{ + background: rgb({BG_DARK.red()}, {BG_DARK.green()}, {BG_DARK.blue()}); + border: 2px solid #444; + }} + QLabel {{ color: white; font-family: 'Rajdhani'; }} + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(15, 15, 15, 15) + + header = QLabel("SELECT A VERSION TO DOWNLOAD") + header.setStyleSheet("font-family: 'Orbitron'; font-weight: bold; font-size: 14px; color: #00f3ff; margin-bottom: 10px;") + layout.addWidget(header) + + self.list_widget = QListWidget() + self.list_widget.setStyleSheet(""" + QListWidget { + background: rgba(0, 0, 0, 0.4); + border: 1px solid #333; + border-radius: 4px; + color: #ddd; + padding: 5px; + } + QListWidget::item { + border-bottom: 1px solid #222; + padding: 8px; + } + QListWidget::item:hover { + background: rgba(0, 243, 255, 0.1); + } + """) + layout.addWidget(self.list_widget) + + for res in results: + # Title ||| Duration ||| URL + parts = res.split(" ||| ") + if len(parts) < 3: continue + + title, duration, url = parts[0], parts[1], parts[2] + + item = QListWidgetItem(self.list_widget) + item.setSizeHint(QSize(0, 50)) + + widget = QWidget() + item_layout = QHBoxLayout(widget) + item_layout.setContentsMargins(5, 0, 5, 0) + + info_vbox = QVBoxLayout() + info_vbox.setSpacing(0) + + title_label = QLabel(title) + title_label.setStyleSheet("font-weight: bold; font-size: 12px; color: #eee;") + title_label.setWordWrap(True) + info_vbox.addWidget(title_label) + + dur_label = QLabel(f"Duration: {duration}") + dur_label.setStyleSheet("font-size: 10px; color: #888;") + info_vbox.addWidget(dur_label) + + item_layout.addLayout(info_vbox, 1) + + dl_btn = NeonButton("DOWNLOAD", PRIMARY_CYAN) + dl_btn.setFixedSize(90, 26) + dl_btn.clicked.connect(lambda _, u=url: self.on_dl_click(u)) + item_layout.addWidget(dl_btn) + + self.list_widget.setItemWidget(item, widget) + + def on_dl_click(self, url): + self.item_selected.emit(url) + self.accept() + + +class TechDJMainWindow(QMainWindow): + """Main window matching web panel layout""" + + def __init__(self): + super().__init__() + + self.server_url = "http://54.37.246.24:5000" + self.cache_dir = Path.home() / ".techdj_cache" + self.cache_dir.mkdir(exist_ok=True) + + self.audio_engine = AudioEngine() + self.library = [] + self.download_threads = {} + self.broadcasting = False + self.broadcast_thread = None + self.listener_count = 0 + self.glow_enabled = {'A': False, 'B': False} + self.glow_intensity = 30 + self.deck_loading_target = {'A': None, 'B': None} + + # Socket.IO for broadcasting + self.socket = None + + # Library settings + self.library_mode = 'server' # 'server' or 'local' + self.server_library = [] + self.local_library = [] + self.local_folder = None + self.load_settings() + + # Search debounce timer + self.search_timer = QTimer() + self.search_timer.setSingleShot(True) + # Search is now fast enough to update quickly + self.search_timer.timeout.connect(lambda: self.update_library_list(rebuild=False)) + + self.init_ui() + + # Set window icon + icon_path = os.path.join(os.path.dirname(__file__), 'icon.png') + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + + self.audio_engine.start_stream() + self.fetch_library() + + def init_ui(self): + self.setWindowTitle("TechDJ Pro - Native Edition") + self.setGeometry(50, 50, 1600, 900) + + # Central widget with overlay support + central = QWidget() + self.setCentralWidget(central) + + # Overall vertical layout for central widget + self.container_layout = QVBoxLayout(central) + self.container_layout.setContentsMargins(0, 0, 0, 0) + self.container_layout.setSpacing(0) + + # --- Download Bar (Minimized) --- + self.download_bar = QWidget() + self.download_bar.setFixedHeight(38) # Reduced from 50 + self.download_bar.setStyleSheet(f""" + QWidget {{ + background: rgb(20, 20, 30); + border-bottom: 1px solid rgba({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}, 0.3); + }} + """) + dl_layout = QHBoxLayout(self.download_bar) + dl_layout.setContentsMargins(10, 2, 10, 2) # Tighten margins + + self.dl_input = QLineEdit() + self.dl_input.setPlaceholderText("Paste URL or Type to Search (YT, SC, etc.)") + self.dl_input.setStyleSheet(f""" + QLineEdit {{ + background: rgba(255, 255, 255, 0.05); + border: 1px solid #333; + color: white; + padding: 4px 12px; + border-radius: 12px; + font-family: 'Rajdhani'; + font-size: 12px; + }} + QLineEdit:focus {{ border: 1px solid rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); }} + """) + self.dl_input.returnPressed.connect(self.start_download) + dl_layout.addWidget(self.dl_input, 1) + + self.dl_btn = NeonButton("GET", SECONDARY_MAGENTA) # Shorter text + self.dl_btn.setFixedSize(60, 26) # Smaller button + self.dl_btn.clicked.connect(self.start_download) + dl_layout.addWidget(self.dl_btn) + + self.dl_progress = QProgressBar() + self.dl_progress.setFixedWidth(120) + self.dl_progress.setFixedHeight(4) + self.dl_progress.setTextVisible(False) + self.dl_progress.setStyleSheet(f""" + QProgressBar {{ background: #111; border: none; border-radius: 2px; }} + QProgressBar::chunk {{ background: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); }} + """) + self.dl_progress.hide() + dl_layout.addWidget(self.dl_progress) + + self.container_layout.addWidget(self.download_bar) + + # Main grid layout matching web panel + main_layout = QHBoxLayout() + # Create a widget to hold main_layout + self.app_content = QWidget() + self.app_content.setLayout(main_layout) + self.container_layout.addWidget(self.app_content, 1) + main_layout.setSpacing(10) + main_layout.setContentsMargins(10, 10, 10, 10) + + # Left: Library (320px) + library_widget = QWidget() + library_widget.setFixedWidth(320) + library_widget.setStyleSheet(f""" + QWidget {{ + background: rgba(20, 20, 30, 0.8); + border: 2px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); + border-radius: 10px; + }} + """) + + library_layout = QVBoxLayout(library_widget) + library_layout.setSpacing(10) + library_layout.setContentsMargins(15, 15, 15, 15) + + lib_header = QLabel("๐Ÿ“ LIBRARY") + lib_header.setStyleSheet(f""" + font-family: 'Orbitron'; + font-size: 16px; + font-weight: bold; + color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); + border: none; + """) + library_layout.addWidget(lib_header) + + # Library Mode Switch + mode_switch_layout = QHBoxLayout() + self.server_mode_btn = NeonButton("SERVER", PRIMARY_CYAN) + self.server_mode_btn.set_active(True) + self.server_mode_btn.clicked.connect(lambda: self.set_library_mode('server')) + + self.local_mode_btn = NeonButton("LOCAL", TEXT_DIM) + self.local_mode_btn.clicked.connect(lambda: self.set_library_mode('local')) + + mode_switch_layout.addWidget(self.server_mode_btn) + mode_switch_layout.addWidget(self.local_mode_btn) + library_layout.addLayout(mode_switch_layout) + + # Local Folder Selection (hidden by default) + self.local_folder_widget = QWidget() + local_folder_layout = QHBoxLayout(self.local_folder_widget) + local_folder_layout.setContentsMargins(0, 0, 0, 0) + + self.folder_label = QLabel("NO FOLDER...") + self.folder_label.setStyleSheet("color: #888; font-size: 10px;") + + select_folder_btn = QPushButton("๐Ÿ“") + select_folder_btn.setFixedSize(30, 30) + select_folder_btn.setStyleSheet("background: #333; border-radius: 4px; color: white;") + select_folder_btn.clicked.connect(self.select_local_folder) + + local_folder_layout.addWidget(self.folder_label, 1) + local_folder_layout.addWidget(select_folder_btn) + self.local_folder_widget.hide() + library_layout.addWidget(self.local_folder_widget) + + self.search_box = QLineEdit() + self.search_box.setPlaceholderText("๐Ÿ” FILTER LIBRARY...") + self.search_box.textChanged.connect(self.filter_library) + self.search_box.setStyleSheet(""" + QLineEdit { + background: rgba(0, 0, 0, 0.3); + border: 1px solid #333; + color: white; + padding: 10px; + border-radius: 4px; + font-family: 'Rajdhani'; + } + """) + library_layout.addWidget(self.search_box) + + self.library_list = QListWidget() + self.library_list.setSpacing(4) # Add spacing between items + self.library_list.setStyleSheet(""" + QListWidget { + background: rgba(0, 0, 0, 0.3); + border: none; + color: white; + outline: none; + } + QListWidget::item { + background: transparent; + border: none; + padding: 0px; + margin: 0px; + } + QListWidget::item:selected { + background: transparent; + } + """) + self.library_list.itemDoubleClicked.connect(self.on_library_double_click) + library_layout.addWidget(self.library_list) + + refresh_btn = QPushButton("๐Ÿ”„ Refresh Library") + refresh_btn.clicked.connect(self.fetch_library) + refresh_btn.setStyleSheet(f""" + QPushButton {{ + background: rgba(0, 243, 255, 0.1); + border: 1px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); + color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); + padding: 8px 12px; + border-radius: 4px; + font-family: 'Orbitron'; + font-weight: bold; + }} + QPushButton:hover {{ + background: rgba(0, 243, 255, 0.2); + }} + """) + library_layout.addWidget(refresh_btn) + + main_layout.addWidget(library_widget) + + # Right: Decks + Crossfader + decks_widget = QWidget() + decks_layout = QVBoxLayout(decks_widget) + decks_layout.setSpacing(10) + decks_layout.setContentsMargins(0, 0, 0, 0) + + # Decks grid + decks_grid = QHBoxLayout() + decks_grid.setSpacing(10) + + self.deck_a = DeckWidget('A', self.audio_engine) + decks_grid.addWidget(self.deck_a) + + self.deck_b = DeckWidget('B', self.audio_engine) + decks_grid.addWidget(self.deck_b) + + decks_layout.addLayout(decks_grid) + + # Crossfader + xfader_widget = QWidget() + xfader_widget.setFixedHeight(80) + xfader_widget.setStyleSheet(""" + QWidget { + background: qlineargradient(x1:0, y1:0, x1:0, y1:1, + stop:0 #1a1a1a, stop:1 #0a0a0a); + border: 2px solid #444; + border-radius: 8px; + } + """) + + # Crossfader Bar (Full Width) + xfader_widget = QWidget() + xfader_widget.setFixedHeight(80) + xfader_widget.setStyleSheet(""" + QWidget { + background: qlineargradient(x1:0, y1:0, x1:0, y1:1, + stop:0 #1a1a1a, stop:1 #0a0a0a); + border: 2px solid #444; + border-radius: 8px; + } + """) + + xfader_layout = QHBoxLayout(xfader_widget) + xfader_layout.setContentsMargins(40, 15, 40, 15) + + label_a = QLabel("A") + label_a.setStyleSheet(f""" + font-family: 'Orbitron'; + font-size: 24px; + font-weight: bold; + color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); + """) + xfader_layout.addWidget(label_a) + + self.crossfader = QSlider(Qt.Horizontal) + self.crossfader.setRange(0, 100) + self.crossfader.setValue(50) + self.crossfader.valueChanged.connect(self.on_crossfader_change) + self.crossfader.setStyleSheet(""" + QSlider::groove:horizontal { + height: 12px; + background: qlineargradient(x1:0, y1:0, x1:1, y1:0, + stop:0 #00f3ff, stop:0.5 #333, stop:1 #bc13fe); + border-radius: 6px; + border: 2px solid #555; + } + QSlider::handle:horizontal { + background: qlineargradient(x1:0, y1:0, x1:0, y1:1, + stop:0 #aaa, stop:1 #666); + border: 3px solid #ccc; + width: 80px; + height: 48px; + margin: -18px 0; + border-radius: 8px; + } + QSlider::handle:horizontal:hover { + background: qlineargradient(x1:0, y1:0, x1:0, y1:1, + stop:0 #ccc, stop:1 #888); + } + """) + xfader_layout.addWidget(self.crossfader, 1) # Give it stretch + + label_b = QLabel("B") + label_b.setStyleSheet(f""" + font-family: 'Orbitron'; + font-size: 24px; + font-weight: bold; + color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); + """) + xfader_layout.addWidget(label_b) + + decks_layout.addWidget(xfader_widget) + + main_layout.addWidget(decks_widget, 1) + + # Floating action buttons (bottom right) + self.create_floating_buttons() + + # Streaming panel (hidden by default) + self.create_streaming_panel() + + # Settings panel (hidden by default) + self.create_settings_panel() + + # Window styling + self.setStyleSheet(f""" + QMainWindow {{ + background: rgb({BG_DARK.red()}, {BG_DARK.green()}, {BG_DARK.blue()}); + }} + QWidget {{ + color: rgb({TEXT_MAIN.red()}, {TEXT_MAIN.green()}, {TEXT_MAIN.blue()}); + font-family: 'Rajdhani', sans-serif; + }} + """) + + # Glow effect timer + self.glow_timer = QTimer() + self.glow_timer.timeout.connect(self.update_glow_effect) + self.glow_timer.start(100) + + def create_floating_buttons(self): + """Create floating action buttons in bottom-right corner""" + button_style = """ + QPushButton { + background: rgba(188, 19, 254, 0.2); + border: 2px solid #bc13fe; + color: white; + font-size: 20px; + border-radius: 25px; + padding: 10px; + } + QPushButton:hover { + background: rgba(188, 19, 254, 0.4); + } + """ + + # Streaming button + self.streaming_btn = QPushButton("๐Ÿ“ก", self) + self.streaming_btn.setFixedSize(50, 50) + self.streaming_btn.setStyleSheet(button_style) + self.streaming_btn.clicked.connect(self.toggle_streaming_panel) + self.streaming_btn.setToolTip("Live Streaming") + self.streaming_btn.move(self.width() - 70, self.height() - 280) + + # Settings button + self.settings_btn = QPushButton("โš™๏ธ", self) + self.settings_btn.setFixedSize(50, 50) + self.settings_btn.setStyleSheet(button_style) + self.settings_btn.clicked.connect(self.toggle_settings_panel) + self.settings_btn.setToolTip("Settings") + self.settings_btn.move(self.width() - 70, self.height() - 220) + + # Upload button + self.upload_btn = QPushButton("๐Ÿ“", self) + self.upload_btn.setFixedSize(50, 50) + self.upload_btn.setStyleSheet(button_style) + self.upload_btn.clicked.connect(self.upload_file) + self.upload_btn.setToolTip("Upload MP3") + self.upload_btn.move(self.width() - 70, self.height() - 160) + + # Keyboard shortcuts button + self.keyboard_btn = QPushButton("โŒจ๏ธ", self) + self.keyboard_btn.setFixedSize(50, 50) + self.keyboard_btn.setStyleSheet(button_style) + self.keyboard_btn.setToolTip("Keyboard Shortcuts") + self.keyboard_btn.move(self.width() - 70, self.height() - 100) + + def create_streaming_panel(self): + """Create streaming panel matching web version""" + self.streaming_panel = QWidget(self) + self.streaming_panel.setFixedSize(400, 500) + self.streaming_panel.setStyleSheet(f""" + QWidget {{ + background: rgba(20, 20, 30, 0.95); + border: 2px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); + border-radius: 10px; + }} + """) + self.streaming_panel.hide() + + layout = QVBoxLayout(self.streaming_panel) + layout.setSpacing(15) + + # Header + header = QHBoxLayout() + title = QLabel("๐Ÿ“ก LIVE STREAM") + title.setStyleSheet(f""" + font-family: 'Orbitron'; + font-size: 16px; + font-weight: bold; + color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); + """) + header.addWidget(title) + header.addStretch() + + close_btn = QPushButton("โœ•") + close_btn.setFixedSize(30, 30) + close_btn.clicked.connect(self.toggle_streaming_panel) + close_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: none; + color: #888; + font-size: 18px; + } + QPushButton:hover { + color: white; + } + """) + header.addWidget(close_btn) + layout.addLayout(header) + + # Broadcast button + self.broadcast_btn = QPushButton("๐Ÿ”ด START BROADCAST") + self.broadcast_btn.setFixedHeight(60) + self.broadcast_btn.clicked.connect(self.toggle_broadcast) + self.broadcast_btn.setStyleSheet(f""" + QPushButton {{ + background: rgba(255, 0, 0, 0.2); + border: 2px solid #ff0000; + color: #ff0000; + font-family: 'Orbitron'; + font-size: 14px; + font-weight: bold; + border-radius: 8px; + }} + QPushButton:hover {{ + background: rgba(255, 0, 0, 0.3); + }} + """) + layout.addWidget(self.broadcast_btn) + + # Status + self.broadcast_status = QLabel("Offline") + self.broadcast_status.setAlignment(Qt.AlignCenter) + self.broadcast_status.setStyleSheet("color: #888; font-size: 12px;") + layout.addWidget(self.broadcast_status) + + # Listener count + listener_widget = QWidget() + listener_layout = QHBoxLayout(listener_widget) + listener_layout.setContentsMargins(0, 0, 0, 0) + + listener_icon = QLabel("๐Ÿ‘‚") + listener_icon.setStyleSheet("font-size: 24px;") + listener_layout.addWidget(listener_icon) + + self.listener_count_label = QLabel("0") + self.listener_count_label.setStyleSheet(f""" + font-family: 'Orbitron'; + font-size: 32px; + font-weight: bold; + color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); + """) + listener_layout.addWidget(self.listener_count_label) + + listener_text = QLabel("Listeners") + listener_text.setStyleSheet("color: #888; font-size: 14px;") + listener_layout.addWidget(listener_text) + listener_layout.addStretch() + + layout.addWidget(listener_widget) + + # Stream URL + url_label = QLabel("Share this URL:") + url_label.setStyleSheet("color: #888; font-size: 12px;") + layout.addWidget(url_label) + + url_widget = QWidget() + url_layout = QHBoxLayout(url_widget) + url_layout.setContentsMargins(0, 0, 0, 0) + url_layout.setSpacing(5) + + self.stream_url = QLineEdit("http://localhost:5001") + self.stream_url.setReadOnly(True) + self.stream_url.setStyleSheet(""" + QLineEdit { + background: rgba(0, 0, 0, 0.3); + border: 1px solid #333; + color: white; + padding: 8px; + border-radius: 4px; + } + """) + url_layout.addWidget(self.stream_url) + + copy_btn = QPushButton("๐Ÿ“‹") + copy_btn.setFixedSize(40, 30) + copy_btn.clicked.connect(self.copy_stream_url) + copy_btn.setStyleSheet(""" + QPushButton { + background: rgba(0, 243, 255, 0.1); + border: 1px solid #00f3ff; + color: #00f3ff; + } + QPushButton:hover { + background: rgba(0, 243, 255, 0.2); + } + """) + url_layout.addWidget(copy_btn) + + layout.addWidget(url_widget) + + # Auto-start checkbox + self.auto_start_check = QCheckBox("Auto-start on play") + self.auto_start_check.setStyleSheet("color: #e0e0e0;") + layout.addWidget(self.auto_start_check) + + # Quality selector + quality_label = QLabel("Stream Quality:") + quality_label.setStyleSheet("color: #888; font-size: 12px;") + layout.addWidget(quality_label) + + self.quality_combo = QComboBox() + self.quality_combo.addItems([ + "High (128kbps)", + "Medium (96kbps)", + "Low (64kbps)", + "Very Low (48kbps)", + "Minimum (32kbps)" + ]) + self.quality_combo.setCurrentIndex(1) + self.quality_combo.setStyleSheet(""" + QComboBox { + background: rgba(0, 0, 0, 0.3); + border: 1px solid #333; + color: white; + padding: 5px; + border-radius: 4px; + } + QComboBox::drop-down { + border: none; + } + QComboBox QAbstractItemView { + background: #1a1a1a; + color: white; + selection-background-color: #00f3ff; + } + """) + layout.addWidget(self.quality_combo) + + hint = QLabel("Lower = more stable on poor connections") + hint.setStyleSheet("color: #666; font-size: 10px;") + layout.addWidget(hint) + + layout.addStretch() + + # Position panel + self.streaming_panel.move(self.width() - 420, 20) + + def create_settings_panel(self): + """Create settings panel with glow controls""" + self.settings_panel = QWidget(self) + self.settings_panel.setFixedSize(400, 600) + self.settings_panel.setStyleSheet(f""" + QWidget {{ + background: rgba(20, 20, 30, 0.95); + border: 2px solid rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); + border-radius: 10px; + }} + """) + self.settings_panel.hide() + + layout = QVBoxLayout(self.settings_panel) + layout.setSpacing(10) + + # Header + header = QHBoxLayout() + title = QLabel("โš™๏ธ SETTINGS") + title.setStyleSheet(f""" + font-family: 'Orbitron'; + font-size: 16px; + font-weight: bold; + color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); + """) + header.addWidget(title) + header.addStretch() + + close_btn = QPushButton("โœ•") + close_btn.setFixedSize(30, 30) + close_btn.clicked.connect(self.toggle_settings_panel) + close_btn.setStyleSheet(""" + QPushButton { + background: transparent; + border: none; + color: #888; + font-size: 18px; + } + QPushButton:hover { + color: white; + } + """) + header.addWidget(close_btn) + layout.addLayout(header) + + # Settings checkboxes + checkbox_style = """ + QCheckBox { + color: #e0e0e0; + font-size: 13px; + spacing: 8px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border: 2px solid #666; + border-radius: 3px; + background: rgba(0, 0, 0, 0.3); + } + QCheckBox::indicator:checked { + background: #bc13fe; + border-color: #bc13fe; + } + """ + + self.repeat_a_check = QCheckBox("๐Ÿ” Repeat Deck A") + self.repeat_a_check.setStyleSheet(checkbox_style) + layout.addWidget(self.repeat_a_check) + + self.repeat_b_check = QCheckBox("๐Ÿ” Repeat Deck B") + self.repeat_b_check.setStyleSheet(checkbox_style) + layout.addWidget(self.repeat_b_check) + + self.auto_mix_check = QCheckBox("๐ŸŽ›๏ธ Auto-Crossfade") + self.auto_mix_check.setStyleSheet(checkbox_style) + layout.addWidget(self.auto_mix_check) + + self.shuffle_check = QCheckBox("๐Ÿ”€ Shuffle Library") + self.shuffle_check.setStyleSheet(checkbox_style) + layout.addWidget(self.shuffle_check) + + self.quantize_check = QCheckBox("๐Ÿ“ Quantize") + self.quantize_check.setStyleSheet(checkbox_style) + layout.addWidget(self.quantize_check) + + self.auto_play_check = QCheckBox("โ–ถ๏ธ Auto-play next") + self.auto_play_check.setChecked(True) + self.auto_play_check.setStyleSheet(checkbox_style) + layout.addWidget(self.auto_play_check) + + # Glow controls + layout.addWidget(QLabel("")) # Spacer + + glow_title = QLabel("โœจ NEON GLOW EFFECTS") + glow_title.setStyleSheet(f""" + font-family: 'Orbitron'; + font-size: 14px; + font-weight: bold; + color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); + """) + layout.addWidget(glow_title) + + self.glow_a_check = QCheckBox("โœจ Glow Deck A (Cyan)") + self.glow_a_check.setStyleSheet(checkbox_style) + self.glow_a_check.stateChanged.connect(lambda: self.toggle_glow('A')) + layout.addWidget(self.glow_a_check) + + self.glow_b_check = QCheckBox("โœจ Glow Deck B (Magenta)") + self.glow_b_check.setStyleSheet(checkbox_style) + self.glow_b_check.stateChanged.connect(lambda: self.toggle_glow('B')) + layout.addWidget(self.glow_b_check) + + # Glow intensity + intensity_label = QLabel("โœจ Glow Intensity") + intensity_label.setStyleSheet("color: #e0e0e0; font-size: 13px;") + layout.addWidget(intensity_label) + + self.glow_slider = QSlider(Qt.Horizontal) + self.glow_slider.setRange(1, 100) + self.glow_slider.setValue(30) + self.glow_slider.valueChanged.connect(self.update_glow_intensity) + self.glow_slider.setStyleSheet(""" + QSlider::groove:horizontal { + height: 8px; + background: #333; + border-radius: 4px; + } + QSlider::handle:horizontal { + background: #bc13fe; + border: 2px solid #bc13fe; + width: 16px; + margin: -4px 0; + border-radius: 8px; + } + """) + layout.addWidget(self.glow_slider) + + # Server URL configuration + layout.addWidget(QLabel("")) # Spacer + server_title = QLabel("๐Ÿ“ก SERVER CONFIGURATION") + server_title.setStyleSheet(f""" + font-family: 'Orbitron'; + font-size: 14px; + font-weight: bold; + color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); + """) + layout.addWidget(server_title) + + server_url_label = QLabel("๐Ÿ”— Server API URL (e.g. http://localhost:5000)") + server_url_label.setStyleSheet("color: #e0e0e0; font-size: 13px;") + layout.addWidget(server_url_label) + + self.server_url_input = QLineEdit(self.server_url) + self.server_url_input.setStyleSheet(""" + background: rgba(0, 0, 0, 0.4); + border: 1px solid #444; + color: cyan; + padding: 5px; + font-family: 'Rajdhani'; + border-radius: 4px; + """) + self.server_url_input.textChanged.connect(self.on_server_url_change) + layout.addWidget(self.server_url_input) + + layout.addStretch() + + # Position panel + self.settings_panel.move(self.width() - 420, 20) + + def load_settings(self): + """Load persistent settings""" + settings_path = Path.home() / ".techdj_settings.json" + if settings_path.exists(): + try: + with open(settings_path, 'r') as f: + data = json.load(f) + self.local_folder = data.get('local_folder') + self.library_mode = data.get('library_mode', 'server') + self.server_url = data.get('server_url', self.server_url) + except Exception as e: + print(f"Error loading settings: {e}") + + def save_settings(self): + """Save persistent settings""" + settings_path = Path.home() / ".techdj_settings.json" + try: + with open(settings_path, 'w') as f: + json.dump({ + 'local_folder': self.local_folder, + 'library_mode': self.library_mode, + 'server_url': self.server_url + }, f) + except Exception as e: + print(f"Error saving settings: {e}") + + def set_library_mode(self, mode): + """Switch between server and local library""" + self.library_mode = mode + + if mode == 'server': + self.server_mode_btn.set_active(True) + self.local_mode_btn.set_active(False) + self.local_folder_widget.hide() + else: + self.server_mode_btn.set_active(False) + self.local_mode_btn.set_active(True) + self.local_folder_widget.show() + if self.local_folder: + self.folder_label.setText(os.path.basename(self.local_folder).upper()) + self.scan_local_library() + + self.update_library_list(rebuild=True) + self.save_settings() + + def select_local_folder(self): + """Open dialog to select local music folder""" + folder = QFileDialog.getExistingDirectory(self, "Select Music Folder") + if folder: + self.local_folder = folder + self.folder_label.setText(os.path.basename(folder).upper()) + self.scan_local_library() + self.update_library_list(rebuild=True) + self.save_settings() + + def on_server_url_change(self, text): + """Update server URL and save""" + self.server_url = text + self.save_settings() + + # Debounce the refresh to avoid spamming while typing + if not hasattr(self, '_refresh_timer'): + self._refresh_timer = QTimer() + self._refresh_timer.timeout.connect(self.fetch_library) + self._refresh_timer.setSingleShot(True) + + self._refresh_timer.start(1500) # Refresh library 1.5s after typing stops + + def scan_local_library(self): + """Scan local folder for audio files""" + if not self.local_folder: + return + + self.local_library = [] + extensions = ('.mp3', '.wav', '.flac', '.ogg', '.m4a') + + try: + for root, dirs, files in os.walk(self.local_folder): + for file in sorted(files): + if file.lower().endswith(extensions): + full_path = os.path.join(root, file) + self.local_library.append({ + "title": os.path.splitext(file)[0], + "file": full_path, + "is_local": True + }) + print(f"๐Ÿ“‚ Found {len(self.local_library)} local tracks") + except Exception as e: + print(f"Error scanning folder: {e}") + + def fetch_library(self): + try: + response = requests.get(f"{self.server_url}/library.json", timeout=5) + self.server_library = response.json() + # Mark server tracks + for track in self.server_library: + track['is_local'] = False + + # Initial mode setup + self.set_library_mode(self.library_mode) + print(f"๐Ÿ“š Loaded {len(self.server_library)} tracks from server") + except Exception as e: + print(f"โŒ Error fetching library: {e}") + # Still set local mode if server fails + self.set_library_mode(self.library_mode) + + def update_library_list(self, rebuild=False): + """Update library results. If rebuild is True, clear and recreate all widgets. + If rebuild is False, just hide/show existing items (much faster).""" + search_term = self.search_box.text().lower() + + # Determine which library to show + library_to_show = self.server_library if self.library_mode == 'server' else self.local_library + + # If we need a full rebuild or the list is empty/wrong size + if rebuild or self.library_list.count() != len(library_to_show): + self.library_list.setUpdatesEnabled(False) + self.library_list.clear() + + for track in library_to_show: + item = QListWidgetItem() + item.setSizeHint(QSize(0, 40)) + item.setData(Qt.UserRole, track) + + widget = QWidget() + widget.setStyleSheet(""" + QWidget { + background: rgba(255, 255, 255, 0.03); + border-radius: 4px; + border-left: 3px solid transparent; + } + QWidget:hover { + background: rgba(255, 255, 255, 0.08); + border-left: 3px solid #00f3ff; + } + """) + item_layout = QHBoxLayout(widget) + item_layout.setContentsMargins(10, 8, 10, 8) + item_layout.setSpacing(5) + + label = QLabel(track['title']) + label.setStyleSheet("font-family: 'Rajdhani'; font-weight: bold; font-size: 13px; color: white; background: transparent;") + item_layout.addWidget(label, 1) + + btn_a = QPushButton("A+") + btn_a.setFixedSize(30, 22) + btn_a.setStyleSheet(f"background: rgba(0, 243, 255, 0.2); border: 1px solid rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); border-radius: 4px; color: rgb({PRIMARY_CYAN.red()}, {PRIMARY_CYAN.green()}, {PRIMARY_CYAN.blue()}); font-size: 9px; font-weight: bold;") + btn_a.clicked.connect(lambda _, t=track: self.add_to_queue('A', t)) + item_layout.addWidget(btn_a) + + btn_b = QPushButton("B+") + btn_b.setFixedSize(30, 22) + btn_b.setStyleSheet(f"background: rgba(188, 19, 254, 0.2); border: 1px solid rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); border-radius: 4px; color: rgb({SECONDARY_MAGENTA.red()}, {SECONDARY_MAGENTA.green()}, {SECONDARY_MAGENTA.blue()}); font-size: 9px; font-weight: bold;") + btn_b.clicked.connect(lambda _, t=track: self.add_to_queue('B', t)) + item_layout.addWidget(btn_b) + + self.library_list.addItem(item) + self.library_list.setItemWidget(item, widget) + + self.library_list.setUpdatesEnabled(True) + + # Apply visibility filter (extremely fast) + self.library_list.setUpdatesEnabled(False) + for i in range(self.library_list.count()): + item = self.library_list.item(i) + track = item.data(Qt.UserRole) + if track: + visible = not search_term or search_term in track['title'].lower() + item.setHidden(not visible) + self.library_list.setUpdatesEnabled(True) + + def filter_library(self): + # Debounce reduced to 100ms for snappier feel + self.search_timer.start(100) + + def on_library_double_click(self, item): + track = item.data(Qt.UserRole) + + dialog = QDialog(self) + dialog.setWindowTitle("Load Track") + dialog.setStyleSheet(f""" + QDialog {{ + background: rgb({BG_DARK.red()}, {BG_DARK.green()}, {BG_DARK.blue()}); + }} + """) + + layout = QVBoxLayout() + layout.addWidget(QLabel(f"Load '{track['title']}' to:")) + + btn_a = NeonButton(f"โ–ถ Play on Deck A", PRIMARY_CYAN) + btn_a.clicked.connect(lambda: self.load_to_deck('A', track, dialog)) + layout.addWidget(btn_a) + + btn_b = NeonButton(f"โ–ถ Play on Deck B", SECONDARY_MAGENTA) + btn_b.clicked.connect(lambda: self.load_to_deck('B', track, dialog)) + layout.addWidget(btn_b) + + # Add to queue buttons + queue_a = NeonButton(f"๐Ÿ“‹ Add to Queue A", PRIMARY_CYAN) + queue_a.clicked.connect(lambda: self.add_to_queue('A', track, dialog)) + layout.addWidget(queue_a) + + queue_b = NeonButton(f"๐Ÿ“‹ Add to Queue B", SECONDARY_MAGENTA) + queue_b.clicked.connect(lambda: self.add_to_queue('B', track, dialog)) + layout.addWidget(queue_b) + + dialog.setLayout(layout) + dialog.exec_() + + def load_to_deck(self, deck_id, track, dialog=None): + if dialog: + dialog.accept() + + if track.get('is_local'): + # Load local file directly + print(f"๐Ÿ“‚ Loading local: {track['file']}") + self.deck_loading_target[deck_id] = track['file'] + if deck_id == 'A': + self.deck_a.load_track(track['file']) + else: + self.deck_b.load_track(track['file']) + return + + filename = os.path.basename(track['file']) + cache_path = self.cache_dir / filename + self.deck_loading_target[deck_id] = str(cache_path) + + if cache_path.exists(): + print(f"๐Ÿ“ฆ Using cached: {filename}") + if deck_id == 'A': + self.deck_a.load_track(str(cache_path)) + else: + self.deck_b.load_track(str(cache_path)) + else: + url = f"{self.server_url}/{track['file']}" + print(f"โฌ‡๏ธ Downloading: {filename}") + + thread = DownloadThread(url, str(cache_path)) + thread.finished.connect(lambda path, success: self.on_download_finished(deck_id, path, success)) + thread.start() + self.download_threads[filename] = thread + + def on_download_finished(self, deck_id, filepath, success): + if success: + # Check if this is still the intended track for this deck + if self.deck_loading_target.get(deck_id) != filepath: + print(f"โญ๏ธ Stale download finished (ignored): {os.path.basename(filepath)}") + return + + print(f"โœ… Downloaded: {os.path.basename(filepath)}") + if deck_id == 'A': + self.deck_a.load_track(filepath) + else: + self.deck_b.load_track(filepath) + else: + QMessageBox.warning(self, "Download Error", "Failed to download track") + + def add_to_queue(self, deck_id, track, dialog=None): + """Add track to deck's queue""" + if dialog: + dialog.accept() + + # Determine file path + if self.library_mode == 'local': + filepath = track['file'] + else: + filename = track['file'].split('/')[-1] + cache_path = self.cache_dir / filename + + if cache_path.exists(): + filepath = str(cache_path) + else: + # Download to cache first + url = f"{self.server_url}/{track['file']}" + print(f"โฌ‡๏ธ Downloading for queue: {filename}") + + thread = DownloadThread(url, str(cache_path)) + thread.finished.connect(lambda path, success: self.on_queue_download_finished(deck_id, path, success)) + thread.start() + self.download_threads[filename] = thread + return + + # Add to queue + self.audio_engine.add_to_queue(deck_id, filepath) + queue_len = len(self.audio_engine.get_queue(deck_id)) + print(f"๐Ÿ“‹ Added to Deck {deck_id} queue: {track['title']} (Queue: {queue_len})") + + if dialog: + QMessageBox.information(self, "Added to Queue", + f"Added '{track['title']}' to Deck {deck_id} queue\n\nQueue length: {queue_len}") + + def on_queue_download_finished(self, deck_id, filepath, success): + """Handle download completion for queued tracks""" + if success: + self.audio_engine.add_to_queue(deck_id, filepath) + queue_len = len(self.audio_engine.get_queue(deck_id)) + print(f"โœ… Downloaded and queued: {os.path.basename(filepath)} (Queue: {queue_len})") + else: + print(f"โŒ Failed to download for queue: {os.path.basename(filepath)}") + + def on_crossfader_change(self, value): + self.audio_engine.set_crossfader(value / 100.0) + + + def toggle_streaming_panel(self): + """Toggle streaming panel visibility""" + if self.streaming_panel.isVisible(): + self.streaming_panel.hide() + else: + self.settings_panel.hide() # Hide settings if open + self.streaming_panel.show() + self.streaming_panel.raise_() + + def toggle_settings_panel(self): + """Toggle settings panel visibility""" + if self.settings_panel.isVisible(): + self.settings_panel.hide() + else: + self.streaming_panel.hide() # Hide streaming if open + self.settings_panel.show() + self.settings_panel.raise_() + + def toggle_broadcast(self): + """Toggle broadcast on/off""" + if not self.broadcasting: + # Start broadcast + try: + if self.socket is None: + print(f"๐Ÿ”Œ Connecting to server: {self.server_url}") + self.socket = socketio.Client(logger=True, engineio_logger=False) + + # Add connection event handlers + @self.socket.on('connect') + def on_connect(): + print("โœ… Socket.IO connected successfully") + + @self.socket.on('connect_error') + def on_connect_error(data): + print(f"โŒ Socket.IO connection error: {data}") + QMessageBox.warning(self, "Connection Error", + f"Failed to connect to server at {self.server_url}\n\nError: {data}") + + @self.socket.on('disconnect') + def on_disconnect(): + print("โš ๏ธ Socket.IO disconnected") + + self.socket.on('listener_count', self.on_listener_count) + + try: + self.socket.connect(self.server_url, wait_timeout=10) + print("โœ… Connection established") + except Exception as e: + print(f"โŒ Connection failed: {e}") + QMessageBox.critical(self, "Connection Failed", + f"Could not connect to {self.server_url}\n\nError: {str(e)}\n\nMake sure the server is running.") + return + + bitrate_map = {0: "128k", 1: "96k", 2: "64k", 3: "48k", 4: "32k"} + bitrate = bitrate_map.get(self.quality_combo.currentIndex(), "96k") + + print(f"๐Ÿ“ก Emitting start_broadcast with bitrate: {bitrate}") + self.socket.emit('start_broadcast', { + 'bitrate': bitrate, + 'format': 'mp3' + }) + + # Start local encoding thread + self.audio_engine.is_broadcasting = True + self.broadcast_thread = BroadcastThread(self.audio_engine.broadcast_queue, bitrate) + self.broadcast_thread.chunk_ready.connect(self.on_broadcast_chunk) + self.broadcast_thread.start() + + self.broadcasting = True + self.broadcast_btn.setText("๐ŸŸข STOP BROADCAST") + self.broadcast_btn.setStyleSheet(""" + QPushButton { + background: rgba(0, 255, 0, 0.2); + border: 2px solid #00ff00; + color: #00ff00; + font-family: 'Orbitron'; + font-size: 14px; + font-weight: bold; + border-radius: 8px; + } + QPushButton:hover { + background: rgba(0, 255, 0, 0.3); + } + """) + self.broadcast_status.setText("๐Ÿ”ด LIVE") + self.broadcast_status.setStyleSheet("color: #00ff00; font-size: 12px; font-weight: bold;") + + print("๐ŸŽ™๏ธ Broadcast started") + except Exception as e: + print(f"โŒ Broadcast error: {e}") + QMessageBox.warning(self, "Broadcast Error", f"Could not start broadcast:\n{e}") + else: + # Stop broadcast + if self.socket and self.socket.connected: + try: + self.socket.emit('stop_broadcast') + except Exception as e: + print(f"โŒ Failed to emit stop_broadcast: {e}") + + self.audio_engine.is_broadcasting = False + if self.broadcast_thread: + self.broadcast_thread.stop() + self.broadcast_thread = None + + self.broadcasting = False + self.broadcast_btn.setText("๐Ÿ”ด START BROADCAST") + self.broadcast_btn.setStyleSheet(""" + QPushButton { + background: rgba(255, 0, 0, 0.2); + border: 2px solid #ff0000; + color: #ff0000; + font-family: 'Orbitron'; + font-size: 14px; + font-weight: bold; + border-radius: 8px; + } + QPushButton:hover { + background: rgba(255, 0, 0, 0.3); + } + """) + self.broadcast_status.setText("Offline") + self.broadcast_status.setStyleSheet("color: #888; font-size: 12px;") + + print("๐Ÿ›‘ Broadcast stopped") + + def on_broadcast_chunk(self, chunk): + """Send encoded chunk to server via Socket.IO""" + if self.socket and self.socket.connected and self.broadcasting: + try: + self.socket.emit('audio_chunk', chunk) + except Exception as e: + print(f"โŒ Failed to send chunk: {e}") + + def on_listener_count(self, data): + """Update listener count from server""" + self.listener_count = data.get('count', 0) + # Update UI if streaming panel is visible + if hasattr(self, 'listener_count_label'): + self.listener_count_label.setText(f"{self.listener_count}") + + def copy_stream_url(self): + """Copy stream URL to clipboard""" + clipboard = QApplication.clipboard() + clipboard.setText(self.stream_url.text()) + + # Show feedback + original_text = self.stream_url.text() + self.stream_url.setText("โœ… Copied!") + QTimer.singleShot(1000, lambda: self.stream_url.setText(original_text)) + + def toggle_glow(self, deck_id): + """Toggle glow effect for a deck""" + if deck_id == 'A': + self.glow_enabled['A'] = self.glow_a_check.isChecked() + else: + self.glow_enabled['B'] = self.glow_b_check.isChecked() + + print(f"โœจ Glow {deck_id}: {self.glow_enabled[deck_id]}") + + def update_glow_intensity(self, value): + """Update glow intensity""" + self.glow_intensity = value + + def update_glow_effect(self): + """Update window glow effect based on settings""" + # This would apply a glow effect to the window border + # For now, just update deck styling + for deck_id in ['A', 'B']: + if self.glow_enabled[deck_id]: + deck_widget = self.deck_a if deck_id == 'A' else self.deck_b + color = PRIMARY_CYAN if deck_id == 'A' else SECONDARY_MAGENTA + opacity = self.glow_intensity / 100.0 + + # Apply glow effect (simplified - could be enhanced with QGraphicsEffect) + deck_widget.setStyleSheet(deck_widget.styleSheet() + f""" + QWidget#deck {{ + box-shadow: 0 0 {self.glow_intensity}px rgba({color.red()}, {color.green()}, {color.blue()}, {opacity}); + }} + """) + + def start_download(self): + """Search or start direct download""" + query = self.dl_input.text().strip() + if not query: + return + + # Determine if it's a URL or search query + is_url = re.match(r'^https?://', query) + + if is_url: + self.perform_actual_download(query) + else: + self.search_youtube(query) + + def search_youtube(self, query): + """Perform metadata search for youtube results""" + self.dl_input.setEnabled(False) + self.dl_btn.setEnabled(False) + self.dl_btn.setText("SEARCHING...") + + venv_path = os.path.join(os.path.dirname(__file__), ".venv/bin/yt-dlp") + yt_dlp_cmd = venv_path if os.path.exists(venv_path) else "yt-dlp" + + cmd = [ + yt_dlp_cmd, + f"ytsearch8:{query}", + "--print", "%(title)s ||| %(duration_string)s ||| %(webpage_url)s", + "--no-playlist", + "--flat-playlist" + ] + + print(f"๐Ÿ” Searching YouTube: {query}") + + self.search_process = QProcess() + self.search_process.finished.connect(self.on_search_finished) + self.search_process.start(cmd[0], cmd[1:]) + + def on_search_finished(self): + """Handle search results and show dialog""" + self.dl_input.setEnabled(True) + self.dl_btn.setEnabled(True) + self.dl_btn.setText("GET") + + # Check for errors + if self.search_process.exitCode() != 0: + err = str(self.search_process.readAllStandardError(), encoding='utf-8') + print(f"โŒ YouTube Search Error: {err}") + QMessageBox.warning(self, "Search Error", f"YouTube search failed:\n\n{err[:200]}...") + return + + output = str(self.search_process.readAllStandardOutput(), encoding='utf-8').strip() + if not output: + QMessageBox.warning(self, "No Results", "No YouTube results found for that query.") + return + + results = [r for r in output.split("\n") if " ||| " in r] + if not results: + QMessageBox.warning(self, "No Results", "Could not parse search results.") + return + + dialog = YouTubeSearchDialog(results, self) + dialog.item_selected.connect(self.perform_actual_download) + dialog.exec_() + + def perform_actual_download(self, url): + """Start the actual yt-dlp download process""" + # Use local folder or default to project's 'music' folder + dl_dir = self.local_folder if self.local_folder else "music" + if not os.path.exists(dl_dir): + os.makedirs(dl_dir, exist_ok=True) + + # Disable input during download + self.dl_input.setEnabled(False) + self.dl_btn.setEnabled(False) + self.dl_progress.setValue(0) + self.dl_progress.show() + + venv_path = os.path.join(os.path.dirname(__file__), ".venv/bin/yt-dlp") + yt_dlp_cmd = venv_path if os.path.exists(venv_path) else "yt-dlp" + + cmd = [ + yt_dlp_cmd, + "--extract-audio", + "--audio-format", "mp3", + "--audio-quality", "0", + "--output", f"{dl_dir}/%(title)s.%(ext)s", + "--no-playlist", + url + ] + + print(f"๐Ÿ“ฅ Starting download: {url}") + + self.dl_process = QProcess() + self.dl_process.readyReadStandardOutput.connect(self.on_dl_ready_read) + self.dl_process.finished.connect(self.on_dl_finished) + self.dl_process.start(cmd[0], cmd[1:]) + + def on_dl_ready_read(self): + """Parse yt-dlp output for progress""" + output = str(self.dl_process.readAllStandardOutput(), encoding='utf-8') + # Look for [download] 45.3% of 10.00MiB at 10.00MiB/s ETA 00:00 + match = re.search(r'\[download\]\s+(\d+\.\d+)%', output) + if match: + percent = float(match.group(1)) + self.dl_progress.setValue(int(percent)) + + def on_dl_finished(self): + """Handle download completion""" + self.dl_input.setEnabled(True) + self.dl_btn.setEnabled(True) + self.dl_progress.hide() + + if self.dl_process.exitCode() == 0: + print("โœ… Download finished successfully") + self.dl_input.clear() + self.fetch_library() # Refresh library to show new track + QMessageBox.information(self, "Download Complete", "Track downloaded and added to library!") + else: + err = str(self.dl_process.readAllStandardError(), encoding='utf-8') + if not err: + err = "Unknown error (check console)" + print(f"โŒ Download failed: {err}") + QMessageBox.warning(self, "Download Failed", f"Error: {err}") + + def upload_file(self): + """Upload MP3 file to server""" + file_path, _ = QFileDialog.getOpenFileName( + self, + "Upload MP3", + "", + "MP3 Files (*.mp3);;All Files (*)" + ) + + if file_path: + try: + filename = os.path.basename(file_path) + with open(file_path, 'rb') as f: + files = {'file': (filename, f, 'audio/mpeg')} + response = requests.post(f"{self.server_url}/upload", files=files) + + if response.json().get('success'): + print(f"โœ… Uploaded: {filename}") + QMessageBox.information(self, "Upload Success", f"Uploaded {filename}") + self.fetch_library() # Refresh library + else: + error = response.json().get('error', 'Unknown error') + QMessageBox.warning(self, "Upload Failed", error) + except Exception as e: + print(f"โŒ Upload error: {e}") + QMessageBox.warning(self, "Upload Error", str(e)) + + def resizeEvent(self, event): + """Handle window resize to reposition floating elements""" + super().resizeEvent(event) + + # Reposition floating buttons + if hasattr(self, 'streaming_btn'): + self.streaming_btn.move(self.width() - 70, self.height() - 280) + self.settings_btn.move(self.width() - 70, self.height() - 220) + self.upload_btn.move(self.width() - 70, self.height() - 160) + self.keyboard_btn.move(self.width() - 70, self.height() - 100) + + # Reposition panels + if hasattr(self, 'streaming_panel'): + self.streaming_panel.move(self.width() - 420, 20) + self.settings_panel.move(self.width() - 420, 20) + + def closeEvent(self, event): + """Clean up resources before closing""" + # Stop broadcast if active + if self.broadcasting: + self.toggle_broadcast() + + # Disconnect Socket.IO + if self.socket and self.socket.connected: + try: + self.socket.disconnect() + print("๐Ÿ”Œ Socket.IO disconnected") + except Exception as e: + print(f"โš ๏ธ Error disconnecting Socket.IO: {e}") + + # Stop audio engine + self.audio_engine.stop_stream() + + # Wait for download threads to finish + for filename, thread in list(self.download_threads.items()): + if thread.isRunning(): + thread.wait(1000) # Wait up to 1 second + + event.accept() + + +def main(): + app = QApplication(sys.argv) + app.setStyle('Fusion') + + # Set dark palette + palette = app.palette() + palette.setColor(palette.Window, BG_DARK) + palette.setColor(palette.WindowText, TEXT_MAIN) + palette.setColor(palette.Base, QColor(15, 15, 20)) + palette.setColor(palette.AlternateBase, QColor(20, 20, 30)) + palette.setColor(palette.Text, TEXT_MAIN) + palette.setColor(palette.Button, QColor(30, 30, 40)) + palette.setColor(palette.ButtonText, TEXT_M AIN) + app.setPalette(palette) + + window = TechDJMainWindow() + window.show() + \ No newline at end of file