diff --git a/compare_memory.py b/compare_memory.py deleted file mode 100755 index 8cfa6cf..0000000 --- a/compare_memory.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -""" -Quick memory usage comparison script -Run this to see the difference between web and native versions -""" - -import subprocess -import time - -def get_process_memory(process_name): - """Get memory usage of a process in MB""" - try: - result = subprocess.run( - ['ps', 'aux'], - capture_output=True, - text=True - ) - - total_mem = 0 - count = 0 - - for line in result.stdout.split('\n'): - if process_name.lower() in line.lower(): - parts = line.split() - if len(parts) > 5: - # RSS is in KB, convert to MB - mem_kb = float(parts[5]) - total_mem += mem_kb / 1024 - count += 1 - - return total_mem, count - except Exception as e: - return 0, 0 - -def main(): - print("=" * 60) - print("TechDJ Memory Usage Comparison") - print("=" * 60) - print() - - # Check Chrome - chrome_mem, chrome_procs = get_process_memory('chrome') - if chrome_mem > 0: - print(f"[CHROME] Chrome (Web Panel):") - print(f" Total Memory: {chrome_mem:.1f} MB") - print(f" Processes: {chrome_procs}") - print() - else: - print("[CHROME] Chrome: Not running") - print() - - # Check PyQt6 - qt_mem, qt_procs = get_process_memory('techdj_qt') - if qt_mem > 0: - print(f"[PYQT6] PyQt6 Native App:") - print(f" Total Memory: {qt_mem:.1f} MB") - print(f" Processes: {qt_procs}") - print() - else: - print("[PYQT6] PyQt6 Native App: Not running") - print() - - # Comparison - if chrome_mem > 0 and qt_mem > 0: - savings = chrome_mem - qt_mem - percent = (savings / chrome_mem) * 100 - - print("=" * 60) - 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) - - print(" Chrome: " + chrome_bar + f" {chrome_mem:.0f}MB") - print(" PyQt6: " + qt_bar + f" {qt_mem:.0f}MB") - print() - - if percent > 50: - print(f" [OK] PyQt6 uses {percent:.0f}% less memory!") - elif percent > 25: - print(f" [OK] PyQt6 uses {percent:.0f}% less memory") - else: - print(f" PyQt6 uses {percent:.0f}% less memory") - - print("=" * 60) - print() - print("Tip: Run both versions and execute this script to compare!") - print() - -if __name__ == '__main__': - main() diff --git a/dj_icon.png b/dj_icon.png deleted file mode 100644 index b856916..0000000 Binary files a/dj_icon.png and /dev/null differ diff --git a/edge_glow_test.html b/edge_glow_test.html deleted file mode 100644 index 639cfa4..0000000 --- a/edge_glow_test.html +++ /dev/null @@ -1,162 +0,0 @@ - - - - - Edge Glow Test - - - - -
-

Edge Glow Test

- - - - -
Status: No glow
-
- - - - - \ No newline at end of file diff --git a/listener.css b/listener.css index 287b161..5015457 100644 --- a/listener.css +++ b/listener.css @@ -268,6 +268,7 @@ body.listener-glow::before { align-items: center; justify-content: center; gap: 10px; + width: 100%; padding: 30px 40px; margin: 30px 0; background: linear-gradient(145deg, #1a1a1a, #0a0a0a); @@ -361,3 +362,19 @@ body.listener-glow::before { margin: 15px 0; } } + +/* ========== Listener Count Badge ========== */ + +.listener-count-badge { + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.7); + margin-left: 8px; + padding-left: 10px; + border-left: 1px solid rgba(188, 19, 254, 0.4); +} + +.listener-count-badge #listener-count { + color: var(--secondary-magenta, #bc13fe); + font-weight: 700; +} diff --git a/listener.html b/listener.html index 359727f..490ccf1 100644 --- a/listener.html +++ b/listener.html @@ -5,7 +5,7 @@ TechDJ Live - + @@ -16,6 +16,7 @@
LIVE + 0 listening
@@ -25,7 +26,7 @@ @@ -40,7 +41,7 @@
- + diff --git a/listener.js b/listener.js index cd0f15b..fcacfb7 100644 --- a/listener.js +++ b/listener.js @@ -1,9 +1,17 @@ // ========== TechDJ Listener ========== // Standalone listener script — no DJ panel code loaded. +// +// State machine: +// Page load → OFFLINE (waiting for Socket.IO stream_status) +// broadcast_started / stream_status(active) → LIVE +// User clicks Enable Audio + LIVE → CONNECTING → PLAYING +// User clicks Enable Audio + OFFLINE → WAITING (auto-connects when LIVE) +// Stream error / stall while LIVE → RECONNECTING (exponential backoff) +// broadcast_stopped → OFFLINE (pause audio, stop watchdog, stop reconnects) 'use strict'; -// --- State --- +// --- Core State --- let socket = null; let listenerAudioContext = null; let listenerGainNode = null; @@ -11,10 +19,21 @@ let listenerAnalyserNode = null; let listenerMediaElementSourceNode = null; let listenerVuMeterRunning = false; +// Broadcast & reconnection state +let broadcastActive = false; +let reconnectAttempts = 0; +let reconnectTimer = null; +const MAX_RECONNECT_DELAY = 30000; // 30 s cap +const BASE_RECONNECT_DELAY = 2000; // Start at 2 s + +// Stall watchdog state +let stallWatchdogInterval = null; +let lastWatchdogTime = 0; +let stallCount = 0; + // --- Helpers --- -function getMp3FallbackUrl() { - // Use same-origin so this works behind reverse proxies (e.g. Cloudflare) +function getMp3StreamUrl() { return `${window.location.origin}/stream.mp3`; } @@ -25,6 +44,56 @@ function updateGlowIntensity(val) { document.documentElement.style.setProperty('--glow-spread', `${spread}px`); } +function updateStatus(text, isActive) { + const el = document.getElementById('connection-status'); + if (!el) return; + el.textContent = text; + el.classList.toggle('glow-text', !!isActive); +} + +function updateNowPlaying(text) { + const el = document.getElementById('listener-now-playing'); + if (el) el.textContent = text; +} + +function updateListenerCount(count) { + const el = document.getElementById('listener-count'); + if (el) el.textContent = count; +} + +// --- Reconnection with Exponential Backoff --- + +function scheduleReconnect() { + if (reconnectTimer) return; // Already scheduled + if (!broadcastActive) return; + if (!window.listenerAudioEnabled) return; + + reconnectAttempts++; + const delay = Math.min( + BASE_RECONNECT_DELAY * Math.pow(1.5, reconnectAttempts - 1), + MAX_RECONNECT_DELAY + ); + console.log(`[RECONNECT] Attempt ${reconnectAttempts} in ${(delay / 1000).toFixed(1)}s`); + updateStatus(`Reconnecting in ${Math.ceil(delay / 1000)}s...`, false); + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connectStream(); + }, delay); +} + +function cancelScheduledReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } +} + +function resetReconnectBackoff() { + reconnectAttempts = 0; + cancelScheduledReconnect(); +} + // --- VU Meter --- function startListenerVUMeter() { @@ -41,7 +110,7 @@ function startListenerVUMeter() { const ctx = canvas.getContext('2d'); if (!ctx) return; - // Keep canvas sized correctly for DPI + // DPI-aware canvas sizing const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const targetW = Math.max(1, Math.floor(rect.width * dpr)); @@ -51,10 +120,9 @@ function startListenerVUMeter() { canvas.height = targetH; } - const analyser = listenerAnalyserNode; - const bufferLength = analyser.frequencyBinCount; + const bufferLength = listenerAnalyserNode.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); - analyser.getByteFrequencyData(dataArray); + listenerAnalyserNode.getByteFrequencyData(dataArray); const width = canvas.width; const height = canvas.height; @@ -64,8 +132,7 @@ function startListenerVUMeter() { ctx.fillStyle = '#0a0a12'; ctx.fillRect(0, 0, width, height); - // Magenta hue (matches Deck B styling) - const hue = 280; + const hue = 280; // Magenta for (let i = 0; i < barCount; i++) { const freqIndex = Math.floor(Math.pow(i / barCount, 1.5) * bufferLength); const value = (dataArray[freqIndex] || 0) / 255; @@ -84,13 +151,101 @@ function startListenerVUMeter() { draw(); } +function stopListenerVUMeter() { + listenerVuMeterRunning = false; + const canvas = document.getElementById('viz-listener'); + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); + } +} + +// --- Stall Watchdog --- + +function startStallWatchdog() { + stopStallWatchdog(); + if (!window.listenerAudio) return; + + lastWatchdogTime = window.listenerAudio.currentTime; + stallCount = 0; + + stallWatchdogInterval = setInterval(() => { + if (!window.listenerAudioEnabled || !window.listenerAudio) return; + if (window.listenerAudio.paused) return; + if (!broadcastActive) { + stopStallWatchdog(); + return; + } + + const currentTime = window.listenerAudio.currentTime; + if (currentTime === lastWatchdogTime && currentTime > 0) { + stallCount++; + console.warn(`[STALL] Stream stall detected (${stallCount}/5)`); + + if (stallCount >= 5) { // 10 seconds of stall + console.error('[STALL] Stream unrecoverable — reconnecting'); + stopStallWatchdog(); + scheduleReconnect(); + } + } else { + stallCount = 0; + } + lastWatchdogTime = currentTime; + }, 2000); +} + +function stopStallWatchdog() { + if (stallWatchdogInterval) { + clearInterval(stallWatchdogInterval); + stallWatchdogInterval = null; + } + stallCount = 0; +} + +// --- Stream Connection --- + +function connectStream() { + if (!window.listenerAudioEnabled || !window.listenerAudio) return; + cancelScheduledReconnect(); + + if (!broadcastActive) { + updateStatus('Waiting for stream to start...', false); + return; + } + + console.log('[STREAM] Connecting to MP3 stream...'); + updateStatus('Connecting...', false); + + // Cache-bust to avoid stale/cached 503 responses + window.listenerAudio.src = getMp3StreamUrl() + '?t=' + Date.now(); + window.listenerAudio.load(); + + window.listenerAudio.play() + .then(() => { + console.log('[OK] Stream playback started'); + resetReconnectBackoff(); + updateStatus('Audio Active — Enjoy the stream!', true); + startStallWatchdog(); + startListenerVUMeter(); + }) + .catch(e => { + console.warn('[WARN] play() rejected:', e.name, e.message); + // If broadcast went offline while we were connecting, don't retry + if (!broadcastActive) { + updateStatus('Stream ended — waiting for DJ...', false); + return; + } + scheduleReconnect(); + }); +} + // --- Socket.IO --- function initSocket() { if (socket) return socket; const serverUrl = window.location.origin; - console.log(`[LISTENER] Initializing Socket.IO connection to: ${serverUrl}`); + console.log(`[SOCKET] Connecting to ${serverUrl}`); socket = io(serverUrl, { transports: ['websocket', 'polling'], @@ -101,364 +256,275 @@ function initSocket() { }); socket.on('connect', () => { - console.log('[OK] Connected to streaming server'); + console.log('[SOCKET] Connected'); + socket.emit('join_listener'); socket.emit('get_listener_count'); + if (window.listenerAudioEnabled) { + updateStatus('Connected', true); + } }); socket.on('connect_error', (error) => { - console.error('[ERROR] Connection error:', error.message); + console.error('[SOCKET] Connection error:', error.message); }); socket.on('disconnect', (reason) => { - console.log(`[ERROR] Disconnected: ${reason}`); + console.warn(`[SOCKET] Disconnected: ${reason}`); + if (window.listenerAudioEnabled) { + updateStatus('Server disconnected — reconnecting...', false); + } }); socket.on('listener_count', (data) => { - const el = document.getElementById('listener-count'); - if (el) el.textContent = data.count; + updateListenerCount(data.count); + }); + + // --- Broadcast lifecycle events --- + + socket.on('stream_status', (data) => { + const wasActive = broadcastActive; + broadcastActive = data.active; + + if (data.active) { + updateNowPlaying('DJ stream is live!'); + // If audio enabled and broadcast just came online, connect + if (!wasActive && window.listenerAudioEnabled) { + resetReconnectBackoff(); + connectStream(); + } + } else { + updateNowPlaying('Stream offline — waiting for DJ...'); + handleBroadcastOffline(); + } + }); + + socket.on('broadcast_started', () => { + console.log('[EVENT] broadcast_started'); + broadcastActive = true; + updateNowPlaying('Stream is live!'); + + if (window.listenerAudioEnabled) { + // Small delay to let the transcoder produce initial data + resetReconnectBackoff(); + setTimeout(() => connectStream(), 800); + } + }); + + socket.on('broadcast_stopped', () => { + console.log('[EVENT] broadcast_stopped'); + broadcastActive = false; + updateNowPlaying('Stream ended'); + handleBroadcastOffline(); }); return socket; } +/** Clean up when broadcast goes offline. */ +function handleBroadcastOffline() { + cancelScheduledReconnect(); + stopStallWatchdog(); + + if (window.listenerAudioEnabled) { + updateStatus('Stream ended — waiting for DJ...', false); + } + + // Pause audio (keep the element alive for when broadcast returns) + if (window.listenerAudio && !window.listenerAudio.paused) { + window.listenerAudio.pause(); + } +} + +// --- Audio Graph Cleanup --- + +function cleanupAudioGraph() { + if (listenerMediaElementSourceNode) { + try { listenerMediaElementSourceNode.disconnect(); } catch (_) { } + listenerMediaElementSourceNode = null; + } + if (listenerAnalyserNode) { + try { listenerAnalyserNode.disconnect(); } catch (_) { } + listenerAnalyserNode = null; + } + if (listenerGainNode) { + try { listenerGainNode.disconnect(); } catch (_) { } + listenerGainNode = null; + } +} + // --- Listener Mode Init --- function initListenerMode() { - console.log('[LISTENER] Initializing listener mode (MP3 stream)...'); - - // Apply glow + console.log('[INIT] TechDJ Listener starting...'); updateGlowIntensity(30); - // Clean up old audio element if it exists (e.g. page refresh) + // Clean up any leftover audio element (e.g. page refresh) if (window.listenerAudio) { - console.log('[CLEAN] Cleaning up old audio element'); try { window.listenerAudio.pause(); - if (window.listenerAudio.src) { - URL.revokeObjectURL(window.listenerAudio.src); - } window.listenerAudio.removeAttribute('src'); + window.listenerAudio.load(); window.listenerAudio.remove(); - } catch (e) { - console.warn('Error cleaning up old audio:', e); - } - - if (listenerMediaElementSourceNode) { - try { listenerMediaElementSourceNode.disconnect(); } catch (_) { } - listenerMediaElementSourceNode = null; - } - if (listenerAnalyserNode) { - try { listenerAnalyserNode.disconnect(); } catch (_) { } - listenerAnalyserNode = null; - } - if (listenerGainNode) { - try { listenerGainNode.disconnect(); } catch (_) { } - listenerGainNode = null; - } - + } catch (_) { } + cleanupAudioGraph(); window.listenerAudio = null; - window.listenerMediaSource = null; - window.listenerAudioEnabled = false; } - // Create fresh audio element + stopStallWatchdog(); + cancelScheduledReconnect(); + stopListenerVUMeter(); + + // Create audio element — but do NOT set src yet. + // Setting src before the user clicks Enable Audio would fire a wasted + // request to /stream.mp3 that returns 503 if no broadcast is active. const audio = document.createElement('audio'); audio.autoplay = false; audio.muted = false; audio.controls = false; audio.playsInline = true; audio.setAttribute('playsinline', ''); - audio.style.display = 'none'; audio.crossOrigin = 'anonymous'; + audio.preload = 'none'; + audio.style.display = 'none'; document.body.appendChild(audio); - console.log('[NEW] Created fresh audio element for listener'); - // --- Stall Watchdog --- - let lastCheckedTime = 0; - let stallCount = 0; - let watchdogInterval = null; + // --- Audio element event handlers --- - 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) { - console.error('[ALERT] Stream stalled. Force reconnecting...'); - reconnectStream(); - stallCount = 0; - } - } else { - stallCount = 0; - } - lastCheckedTime = audio.currentTime; - }, 2000); - }; - - const reconnectStream = () => { - if (!window.listenerAudioEnabled || !window.listenerAudio) return; - - console.log('[RECONNECT] 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; - 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'); - } - startListenerVUMeter(); - }) - .catch(e => console.warn('Reconnect play failed:', e)); - } - }; - - // Set MP3 stream source - audio.src = getMp3FallbackUrl(); - audio.load(); - console.log(`[STREAM] Listener source set to MP3 stream: ${audio.src}`); - - // Auto-reconnect on stream error audio.onerror = () => { if (!window.listenerAudioEnabled) return; - console.error('[ERROR] Audio stream error!'); - reconnectStream(); + const err = audio.error; + console.error(`[AUDIO] Error: code=${err?.code}, msg=${err?.message}`); + + if (broadcastActive) { + scheduleReconnect(); + } else { + updateStatus('Stream offline', false); + } }; audio.onplay = () => { - console.log('[PLAY] Stream playing'); - startWatchdog(); - const statusEl = document.getElementById('connection-status'); - if (statusEl) statusEl.classList.add('glow-text'); + startStallWatchdog(); + if (window.listenerAudioEnabled && broadcastActive) { + updateStatus('Audio Active — Enjoy the stream!', true); + } }; audio.onpause = () => { - console.log('[PAUSE] Stream paused'); - stopWatchdog(); + stopStallWatchdog(); }; - // Show enable audio button - const enableAudioBtn = document.getElementById('enable-audio-btn'); - const statusEl = document.getElementById('connection-status'); + audio.onwaiting = () => { + if (window.listenerAudioEnabled) { + updateStatus('Buffering...', false); + } + }; - if (enableAudioBtn) { - enableAudioBtn.style.display = 'flex'; - } - if (statusEl) { - statusEl.textContent = '[INFO] Click "Enable Audio" to start listening (MP3)'; - } + audio.onplaying = () => { + if (window.listenerAudioEnabled && broadcastActive) { + updateStatus('Audio Active — Enjoy the stream!', true); + } + }; - // Store for later activation + // Store globally window.listenerAudio = audio; - window.listenerMediaSource = null; window.listenerAudioEnabled = false; - // Initialise socket and join + // Initial UI + const enableAudioBtn = document.getElementById('enable-audio-btn'); + if (enableAudioBtn) enableAudioBtn.style.display = 'flex'; + updateStatus('Click "Enable Audio" to start listening', false); + + // Connect socket (stream_status event will set broadcastActive) initSocket(); - socket.emit('join_listener'); - - // --- Socket event handlers --- - - socket.on('broadcast_started', () => { - const nowPlayingEl = document.getElementById('listener-now-playing'); - if (nowPlayingEl) nowPlayingEl.textContent = 'Stream is live!'; - - if (window.listenerAudio) { - console.log('[BROADCAST] Broadcast started: Refreshing audio stream...'); - const wasPlaying = !window.listenerAudio.paused; - window.listenerAudio.src = getMp3FallbackUrl(); - window.listenerAudio.load(); - if (wasPlaying || window.listenerAudioEnabled) { - window.listenerAudio.play().catch(e => console.warn('Auto-play after refresh blocked:', e)); - } - } - }); - - socket.on('stream_status', (data) => { - const nowPlayingEl = document.getElementById('listener-now-playing'); - if (nowPlayingEl) { - if (data.active) { - const status = data.remote_relay ? 'Remote stream is live!' : 'DJ stream is live!'; - nowPlayingEl.textContent = status; - } else { - nowPlayingEl.textContent = 'Stream offline - waiting for DJ...'; - } - } - }); - - socket.on('broadcast_stopped', () => { - const nowPlayingEl = document.getElementById('listener-now-playing'); - if (nowPlayingEl) nowPlayingEl.textContent = 'Stream ended'; - }); - - socket.on('connect', () => { - const statusEl = document.getElementById('connection-status'); - if (statusEl && window.listenerAudioEnabled) { - statusEl.textContent = '[ACTIVE] Connected'; - } - socket.emit('join_listener'); - }); - - socket.on('disconnect', () => { - const statusEl = document.getElementById('connection-status'); - if (statusEl) statusEl.textContent = '[OFFLINE] Disconnected'; - }); } -// --- Enable Audio (user gesture required) --- +// --- Enable Audio (requires user gesture) --- async function enableListenerAudio() { - console.log('[LISTENER] Enabling audio via user gesture...'); + console.log('[ENABLE] User clicked Enable Audio'); - const enableAudioBtn = document.getElementById('enable-audio-btn'); - const statusEl = document.getElementById('connection-status'); - const audioText = enableAudioBtn ? enableAudioBtn.querySelector('.audio-text') : null; + const btn = document.getElementById('enable-audio-btn'); + const audioText = btn?.querySelector('.audio-text'); + const audioSubtitle = btn?.querySelector('.audio-subtitle'); if (audioText) audioText.textContent = 'INITIALIZING...'; + if (audioSubtitle) audioSubtitle.textContent = ''; try { - // 1. Create AudioContext if needed + // 1. Create/resume AudioContext (must happen inside a user gesture) if (!listenerAudioContext) { listenerAudioContext = new (window.AudioContext || window.webkitAudioContext)(); } - - // 2. Resume audio context (CRITICAL for Chrome/Safari) if (listenerAudioContext.state === 'suspended') { await listenerAudioContext.resume(); - console.log('[OK] Audio context resumed'); } + console.log('[OK] AudioContext active'); - // 3. Bridge Audio Element to AudioContext + // 2. Build audio graph: source → analyser → gain → speakers if (window.listenerAudio) { try { if (!listenerGainNode) { listenerGainNode = listenerAudioContext.createGain(); - listenerGainNode.gain.value = 0.8; listenerGainNode.connect(listenerAudioContext.destination); } - if (!listenerAnalyserNode) { listenerAnalyserNode = listenerAudioContext.createAnalyser(); listenerAnalyserNode.fftSize = 256; } - if (!listenerMediaElementSourceNode) { - listenerMediaElementSourceNode = listenerAudioContext.createMediaElementSource(window.listenerAudio); + listenerMediaElementSourceNode = + listenerAudioContext.createMediaElementSource(window.listenerAudio); } - // Clean single connection chain: media -> analyser -> gain -> destination + // Wire up (disconnect first to avoid double-connections) try { listenerMediaElementSourceNode.disconnect(); } catch (_) { } try { listenerAnalyserNode.disconnect(); } catch (_) { } - listenerMediaElementSourceNode.connect(listenerAnalyserNode); listenerAnalyserNode.connect(listenerGainNode); - - window.listenerAudio._connectedToContext = true; - console.log('[OK] Connected audio element to AudioContext (with analyser)'); - - startListenerVUMeter(); + console.log('[OK] Audio graph connected'); } catch (e) { - console.warn('Could not connect to AudioContext:', e.message); + // Non-fatal: audio still plays, just no VU meter + console.warn('[WARN] Audio graph setup failed:', e.message); } } - // 4. Prepare and start audio playback - if (window.listenerAudio) { - window.listenerAudio.muted = false; - window.listenerAudio.volume = 1.0; + // 3. Apply volume + window.listenerAudio.muted = false; + window.listenerAudio.volume = 1.0; + const volEl = document.getElementById('listener-volume'); + setListenerVolume(volEl ? parseInt(volEl.value, 10) || 80 : 80); - const volEl = document.getElementById('listener-volume'); - const volValue = volEl ? parseInt(volEl.value, 10) : 80; - setListenerVolume(Number.isFinite(volValue) ? volValue : 80); + // 4. Mark enabled + window.listenerAudioEnabled = true; - const hasBufferedData = () => { - return window.listenerAudio.buffered && window.listenerAudio.buffered.length > 0; - }; - - window.listenerAudioEnabled = true; - - if (audioText) audioText.textContent = 'STARTING...'; - console.log('[PLAY] Attempting to play audio...'); - - const playTimeout = setTimeout(() => { - if (!hasBufferedData()) { - console.warn('[WARN] Audio play is taking a long time (buffering)...'); - if (audioText) audioText.textContent = 'STILL BUFFERING...'; - window.listenerAudio.load(); - } - }, 8000); - - const playPromise = window.listenerAudio.play(); - - if (!hasBufferedData() && audioText) { - audioText.textContent = 'BUFFERING...'; - } - - try { - await playPromise; - clearTimeout(playTimeout); - console.log('[OK] Audio playback started successfully'); - } catch (e) { - clearTimeout(playTimeout); - throw e; - } + // 5. Hide button with a smooth transition + if (btn) { + btn.style.opacity = '0'; + btn.style.pointerEvents = 'none'; + setTimeout(() => { btn.style.display = 'none'; }, 300); } - // 5. Hide the button and update status - if (enableAudioBtn) { - enableAudioBtn.style.opacity = '0'; - setTimeout(() => { - enableAudioBtn.style.display = 'none'; - }, 300); - } - - if (statusEl) { - statusEl.textContent = '[ACTIVE] Audio Active - Enjoy the stream'; - statusEl.classList.add('glow-text'); + // 6. Start streaming if broadcast is active, otherwise wait + if (broadcastActive) { + if (audioText) audioText.textContent = 'CONNECTING...'; + connectStream(); + } else { + updateStatus('Audio enabled — waiting for stream to start...', false); + updateNowPlaying('Waiting for DJ to start streaming...'); } } catch (error) { - console.error('[ERROR] Failed to enable audio:', error); - const stashedBtn = document.getElementById('enable-audio-btn'); - const stashedStatus = document.getElementById('connection-status'); - const aText = stashedBtn ? stashedBtn.querySelector('.audio-text') : null; + console.error('[ERROR] Enable audio failed:', error); + window.listenerAudioEnabled = false; - if (aText) aText.textContent = 'RETRY ENABLE'; - if (stashedStatus) { - let errorMsg = error.name + ': ' + error.message; - if (error.name === 'NotAllowedError') { - errorMsg = 'Browser blocked audio (NotAllowedError). Check permissions.'; - } else if (error.name === 'NotSupportedError') { - errorMsg = 'MP3 stream not supported or unavailable (NotSupportedError).'; - } - stashedStatus.textContent = errorMsg; + if (audioText) audioText.textContent = 'TAP TO RETRY'; + if (audioSubtitle) audioSubtitle.textContent = ''; + if (btn) btn.style.pointerEvents = 'auto'; - if (error.name === 'NotSupportedError') { - stashedStatus.textContent = 'MP3 stream failed. Is ffmpeg installed on the server?'; - } + if (error.name === 'NotAllowedError') { + updateStatus('Browser blocked audio — tap the button again.', false); + } else { + updateStatus(`Error: ${error.message}`, false); } } } @@ -467,7 +533,7 @@ async function enableListenerAudio() { function setListenerVolume(value) { if (listenerGainNode) { - listenerGainNode.gain.value = value / 100; + listenerGainNode.gain.value = Math.max(0, Math.min(1, value / 100)); } } diff --git a/script.js b/script.js index 52171b5..728dd0f 100644 --- a/script.js +++ b/script.js @@ -470,13 +470,6 @@ function switchQueueTab() { -function toggleMobileLibrary() { - // This is now handled by tabs, but keep for compatibility if needed - const lib = document.querySelector('.library-section'); - lib.classList.toggle('active'); - vibrate(20); -} - // Mobile Haptic Helper function vibrate(ms) { if (navigator.vibrate) { @@ -608,9 +601,13 @@ function drawWaveform(id) { const data = decks[id].waveformData; if (!data) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - const width = canvas.width; - const height = canvas.height; + // Use logical (CSS) dimensions — the canvas context has ctx.scale(dpr) + // applied in initSystem, so coordinates must be in logical pixels. + const dpr = window.devicePixelRatio || 1; + const width = canvas.width / dpr; + const height = canvas.height / dpr; + + ctx.clearRect(0, 0, width, height); const barWidth = width / data.length; data.forEach((val, i) => { @@ -804,8 +801,6 @@ function playDeck(id) { if (deckEl) deckEl.classList.add('playing'); document.body.classList.add('playing-' + id); - - if (audioCtx.state === 'suspended') { console.log(`[Deck ${id}] Resuming suspended AudioContext`); audioCtx.resume(); @@ -824,8 +819,6 @@ function playDeck(id) { const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.remove('playing'); document.body.classList.remove('playing-' + id); - - alert(`Playback error: ${error.message}`); } } else { @@ -1394,13 +1387,6 @@ function updateLibraryHighlighting() { }); } -// Utility function no longer needed but kept for future use -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - function filterLibrary() { const query = document.getElementById('lib-search').value.toLowerCase(); const filtered = allSongs.filter(s => s.title.toLowerCase().includes(query)); @@ -1411,7 +1397,6 @@ function refreshLibrary() { fetchLibrary(); } - async function loadFromServer(id, url, title) { const d = document.getElementById('display-' + id); d.innerText = '[WAIT] LOADING...'; @@ -1839,9 +1824,7 @@ function initSocket() { if (socket) return socket; const serverUrl = window.location.origin; - console.log(`CONNECT Initializing Socket.IO connection to: ${serverUrl}`); - console.log(` Protocol: ${window.location.protocol}`); - console.log(` Host: ${window.location.host}`); + console.log(`[SOCKET] Connecting to ${serverUrl}`); socket = io(serverUrl, { transports: ['websocket', 'polling'], @@ -1853,21 +1836,15 @@ function initSocket() { socket.on('connect', () => { console.log('[OK] Connected to streaming server'); - console.log(` Socket ID: ${socket.id}`); - console.log(` Transport: ${socket.io.engine.transport.name}`); - - // Get initial listener count soon as we connect socket.emit('get_listener_count'); }); socket.on('connect_error', (error) => { console.error('[ERROR] Connection error:', error.message); - console.error(' Make sure server is running on', serverUrl); }); socket.on('disconnect', (reason) => { - console.log('[ERROR] Disconnected from streaming server'); - console.log(` Reason: ${reason}`); + console.warn(`[WARN] Disconnected: ${reason}`); }); socket.on('listener_count', (data) => { @@ -1876,7 +1853,7 @@ function initSocket() { }); socket.on('broadcast_started', () => { - console.log('Broadcast started notification received'); + console.log('[EVENT] broadcast_started'); // Update relay UI if it's a relay const relayStatus = document.getElementById('relay-status'); if (relayStatus && relayStatus.textContent.includes('Connecting')) { @@ -1886,7 +1863,7 @@ function initSocket() { }); socket.on('broadcast_stopped', () => { - console.log('STOP Broadcast stopped notification received'); + console.log('[EVENT] broadcast_stopped'); // Reset relay UI if it was active const startRelayBtn = document.getElementById('start-relay-btn'); const stopRelayBtn = document.getElementById('stop-relay-btn'); @@ -2025,7 +2002,7 @@ function startBroadcast() { isBroadcasting = true; document.getElementById('broadcast-btn').classList.add('active'); document.getElementById('broadcast-text').textContent = 'STOP BROADCAST'; - document.getElementById('broadcast-status').textContent = '[OFFLINE] LIVE'; + document.getElementById('broadcast-status').textContent = 'LIVE'; document.getElementById('broadcast-status').classList.add('live'); if (!socket) initSocket(); @@ -2228,7 +2205,7 @@ function startBroadcast() { isBroadcasting = true; document.getElementById('broadcast-btn').classList.add('active'); document.getElementById('broadcast-text').textContent = 'STOP BROADCAST'; - document.getElementById('broadcast-status').textContent = '[OFFLINE] LIVE'; + document.getElementById('broadcast-status').textContent = 'LIVE'; document.getElementById('broadcast-status').classList.add('live'); // Notify server that broadcast is active (listeners use MP3 stream) @@ -2259,7 +2236,7 @@ function startBroadcast() { // Stop broadcasting function stopBroadcast() { - console.log('STOP Stopping broadcast...'); + console.log('[BROADCAST] Stopping...'); if (SERVER_SIDE_AUDIO) { isBroadcasting = false; @@ -2313,7 +2290,6 @@ function stopBroadcast() { console.log('[OK] Broadcast stopped'); } - // Restart broadcasting (for auto-recovery) function restartBroadcast() { console.log('Restarting broadcast...'); @@ -2367,19 +2343,31 @@ function restartBroadcast() { // Copy stream URL to clipboard function copyStreamUrl(evt) { const urlInput = document.getElementById('stream-url'); - urlInput.select(); - urlInput.setSelectionRange(0, 99999); // For mobile + const text = urlInput.value; + const btn = evt?.target; - try { - document.execCommand('copy'); - const btn = evt?.target; + const showFeedback = (success) => { + if (!btn) return; const originalText = btn.textContent; - btn.textContent = 'OK'; - setTimeout(() => { - btn.textContent = originalText; - }, 2000); - } catch (err) { - console.error('Failed to copy:', err); + btn.textContent = success ? 'OK' : 'FAIL'; + setTimeout(() => { btn.textContent = originalText; }, 2000); + }; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text) + .then(() => showFeedback(true)) + .catch(() => showFeedback(false)); + } else { + // Fallback for older browsers + urlInput.select(); + urlInput.setSelectionRange(0, 99999); + try { + document.execCommand('copy'); + showFeedback(true); + } catch (err) { + console.error('Failed to copy:', err); + showFeedback(false); + } } } @@ -2416,7 +2404,10 @@ function monitorTrackEnd() { const remaining = decks[id].duration - current; // If end reached (with 0.5s buffer for safety) - if (remaining <= 0.5) { + // Skip if a loop is active — the Web Audio API handles looping natively + // and lastAnchorPosition is not updated on each native loop repeat, so + // the monotonically-growing `current` would incorrectly trigger end-of-track. + if (remaining <= 0.5 && !decks[id].loopActive) { // During broadcast, still handle auto-play/queue to avoid dead air if (isBroadcasting) { console.log(`Track ending during broadcast on Deck ${id}`); @@ -2482,6 +2473,7 @@ function monitorTrackEnd() { }, 500); // Check every 0.5s } monitorTrackEnd(); + // Reset Deck to Default Settings function resetDeck(id) { vibrate(20); @@ -2555,7 +2547,6 @@ function resetDeck(id) { console.log(`[OK] Deck ${id} reset complete!`); - // Visual feedback const resetBtn = document.querySelector(`#deck-${id} .reset-btn`); if (resetBtn) { @@ -2966,8 +2957,12 @@ function renderKeyboardMappings() { ${formatKeyName(key)} RIGHT ${mapping.label} - `; + const changeBtn = document.createElement('button'); + changeBtn.className = 'key-reassign-btn'; + changeBtn.textContent = 'Change'; + changeBtn.addEventListener('click', (e) => reassignKey(key, e)); + item.appendChild(changeBtn); list.appendChild(item); }); } diff --git a/server.py b/server.py index 8af94d8..2a747e7 100644 --- a/server.py +++ b/server.py @@ -13,7 +13,6 @@ import collections from flask import Flask, send_from_directory, jsonify, request, session, Response, stream_with_context, abort from flask_socketio import SocketIO, emit from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() @@ -47,23 +46,27 @@ CONFIG_DEBUG = bool(CONFIG.get('debug', False)) DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip() DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD) -# Relay State +# Broadcast State broadcast_state = { 'active': False, } listener_sids = set() dj_sids = set() +# Grace-period greenlet: auto-stop broadcast if DJ doesn't reconnect in time +_dj_grace_greenlet = None +DJ_GRACE_PERIOD_SECS = 20 # seconds to wait before auto-stopping + # === Optional MP3 fallback stream (server-side transcoding) === _ffmpeg_proc = None -_ffmpeg_in_q = queue.Queue(maxsize=20) # Optimized for low-latency live streaming +_ffmpeg_in_q = queue.Queue(maxsize=20) _current_bitrate = (CONFIG.get('stream_bitrate') or '192k').strip() _mp3_clients = set() # set[queue.Queue] _mp3_lock = threading.Lock() _transcoder_bytes_out = 0 _transcoder_last_error = None _last_audio_chunk_ts = 0.0 -_mp3_preroll = collections.deque(maxlen=512) # Larger pre-roll (~512KB) +_mp3_preroll = collections.deque(maxlen=512) def _start_transcoder_if_needed(is_mp3_input=False): @@ -254,7 +257,6 @@ def _load_settings(): return {} SETTINGS = _load_settings() -# Config.json music_folder overrides settings.json if set _config_music = (CONFIG.get('music_folder') or '').strip() MUSIC_FOLDER = _config_music or SETTINGS.get('library', {}).get('music_folder', 'music') @@ -275,8 +277,6 @@ def setup_shared_routes(app, index_file='index.html'): library = [] global MUSIC_FOLDER if os.path.exists(MUSIC_FOLDER): - # 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')): @@ -285,7 +285,7 @@ def setup_shared_routes(app, index_file='index.html'): "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 + break # Top-level only return jsonify(library) @app.route('/music_proxy/') @@ -421,15 +421,33 @@ def setup_shared_routes(app, index_file='index.html'): @app.route('/stream.mp3') def stream_mp3(): - # Streaming response from the ffmpeg transcoder output. - # If ffmpeg isn't available, return 503. - if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: - return jsonify({"success": False, "error": "MP3 stream not available"}), 503 + """Live MP3 audio stream from the ffmpeg transcoder.""" + # If broadcast is not active, return 503 with audio/mpeg content type. + # Returning JSON here would confuse the browser's