// ========== 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'; // --- Core State --- let socket = null; let listenerAudioContext = null; let listenerGainNode = null; 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 // Guard: prevents two simultaneous connectStream() calls (e.g. stream_status and // broadcast_started can arrive back-to-back and both call connectStream) let _streamConnecting = false; // Stall watchdog state let stallWatchdogInterval = null; let lastWatchdogTime = 0; let stallCount = 0; // --- Helpers --- function getMp3StreamUrl() { return `${window.location.origin}/stream.mp3`; } function updateGlowIntensity(val) { const opacity = parseInt(val, 10) / 100; const spread = (parseInt(val, 10) / 100) * 80; document.documentElement.style.setProperty('--glow-opacity', opacity); 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; } // --- 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() { if (listenerVuMeterRunning) return; listenerVuMeterRunning = true; const draw = () => { if (!listenerVuMeterRunning) return; requestAnimationFrame(draw); const canvas = document.getElementById('viz-listener'); if (!canvas || !listenerAnalyserNode) return; const ctx = canvas.getContext('2d'); if (!ctx) return; // DPI-aware canvas sizing const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const targetW = Math.max(1, Math.floor(rect.width * dpr)); const targetH = Math.max(1, Math.floor(rect.height * dpr)); if (canvas.width !== targetW || canvas.height !== targetH) { canvas.width = targetW; canvas.height = targetH; } const bufferLength = listenerAnalyserNode.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); listenerAnalyserNode.getByteFrequencyData(dataArray); const width = canvas.width; const height = canvas.height; const barCount = 32; const barWidth = width / barCount; ctx.fillStyle = '#0a0a12'; ctx.fillRect(0, 0, width, height); 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; const barHeight = value * height; const lightness = 30 + (value * 50); const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight); gradient.addColorStop(0, `hsl(${hue}, 100%, ${lightness}%)`); gradient.addColorStop(1, `hsl(${hue}, 100%, ${Math.min(lightness + 20, 80)}%)`); ctx.fillStyle = gradient; ctx.fillRect(i * barWidth, height - barHeight, barWidth - 2, barHeight); } }; 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; } // Prevent simultaneous duplicate connection attempts if (_streamConnecting) return; _streamConnecting = true; 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(() => { _streamConnecting = false; console.log('[OK] Stream playback started'); resetReconnectBackoff(); updateStatus('Audio Active — Enjoy the stream!', true); startStallWatchdog(); startListenerVUMeter(); }) .catch(e => { _streamConnecting = false; 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(`[SOCKET] Connecting to ${serverUrl}`); socket = io(serverUrl, { transports: ['websocket', 'polling'], reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 10000 }); socket.on('connect', () => { console.log('[SOCKET] Connected'); socket.emit('join_listener'); if (window.listenerAudioEnabled) { updateStatus('Connected', true); } }); socket.on('connect_error', (error) => { console.error('[SOCKET] Connection error:', error.message); }); socket.on('disconnect', (reason) => { console.warn(`[SOCKET] Disconnected: ${reason}`); if (window.listenerAudioEnabled) { updateStatus('Server disconnected — reconnecting...', false); } }); // --- 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) { // Brief delay: 300ms is enough for ffmpeg to produce its first output // (was 800ms — reduced to cut perceived startup lag) resetReconnectBackoff(); setTimeout(() => connectStream(), 300); } }); socket.on('broadcast_stopped', () => { console.log('[EVENT] broadcast_stopped'); broadcastActive = false; updateNowPlaying('Stream ended'); handleBroadcastOffline(); }); socket.on('listener_glow', (data) => { updateGlowIntensity(data.intensity ?? 30); }); socket.on('deck_glow', (data) => { document.body.classList.toggle('playing-A', !!data.A); document.body.classList.toggle('playing-B', !!data.B); }); socket.on('now_playing', (data) => { if (data && data.title) { updateNowPlaying(data.title); } }); return socket; } /** Clean up when broadcast goes offline. */ function handleBroadcastOffline() { _streamConnecting = false; // cancel any in-flight connect attempt 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('[INIT] TechDJ Listener starting...'); updateGlowIntensity(30); // Clean up any leftover audio element (e.g. page refresh) if (window.listenerAudio) { try { window.listenerAudio.pause(); window.listenerAudio.removeAttribute('src'); window.listenerAudio.load(); window.listenerAudio.remove(); } catch (_) { } cleanupAudioGraph(); window.listenerAudio = null; } 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.crossOrigin = 'anonymous'; audio.preload = 'none'; audio.style.display = 'none'; document.body.appendChild(audio); // --- Audio element event handlers --- audio.onerror = () => { if (!window.listenerAudioEnabled) return; const err = audio.error; console.error(`[AUDIO] Error: code=${err?.code}, msg=${err?.message}`); if (broadcastActive) { scheduleReconnect(); } else { updateStatus('Stream offline', false); } }; audio.onplay = () => { startStallWatchdog(); if (window.listenerAudioEnabled && broadcastActive) { updateStatus('Audio Active — Enjoy the stream!', true); } }; audio.onpause = () => { stopStallWatchdog(); }; audio.onwaiting = () => { if (window.listenerAudioEnabled) { updateStatus('Buffering...', false); } }; audio.onplaying = () => { if (window.listenerAudioEnabled && broadcastActive) { updateStatus('Audio Active — Enjoy the stream!', true); } }; // Store globally window.listenerAudio = audio; window.listenerAudioEnabled = false; // 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(); } // --- Enable Audio (requires user gesture) --- async function enableListenerAudio() { console.log('[ENABLE] User clicked Enable Audio'); 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/resume AudioContext (must happen inside a user gesture) if (!listenerAudioContext) { listenerAudioContext = new (window.AudioContext || window.webkitAudioContext)(); } if (listenerAudioContext.state === 'suspended') { await listenerAudioContext.resume(); } console.log('[OK] AudioContext active'); // 2. Build audio graph: source → analyser → gain → speakers if (window.listenerAudio) { try { if (!listenerGainNode) { listenerGainNode = listenerAudioContext.createGain(); listenerGainNode.connect(listenerAudioContext.destination); } if (!listenerAnalyserNode) { listenerAnalyserNode = listenerAudioContext.createAnalyser(); listenerAnalyserNode.fftSize = 256; } if (!listenerMediaElementSourceNode) { listenerMediaElementSourceNode = listenerAudioContext.createMediaElementSource(window.listenerAudio); } // Wire up (disconnect first to avoid double-connections) try { listenerMediaElementSourceNode.disconnect(); } catch (_) { } try { listenerAnalyserNode.disconnect(); } catch (_) { } listenerMediaElementSourceNode.connect(listenerAnalyserNode); listenerAnalyserNode.connect(listenerGainNode); console.log('[OK] Audio graph connected'); } catch (e) { // Non-fatal: audio still plays, just no VU meter console.warn('[WARN] Audio graph setup failed:', e.message); } } // 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); // 4. Mark enabled window.listenerAudioEnabled = true; // 5. Hide button with a smooth transition if (btn) { btn.style.opacity = '0'; btn.style.pointerEvents = 'none'; setTimeout(() => { btn.style.display = 'none'; }, 300); } // 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] Enable audio failed:', error); window.listenerAudioEnabled = false; if (audioText) audioText.textContent = 'TAP TO RETRY'; if (audioSubtitle) audioSubtitle.textContent = ''; if (btn) btn.style.pointerEvents = 'auto'; if (error.name === 'NotAllowedError') { updateStatus('Browser blocked audio — tap the button again.', false); } else { updateStatus(`Error: ${error.message}`, false); } } } // --- Volume --- function setListenerVolume(value) { if (listenerGainNode) { listenerGainNode.gain.value = Math.max(0, Math.min(1, value / 100)); } } // --- Boot --- document.addEventListener('DOMContentLoaded', () => { initListenerMode(); });