// ========== TechDJ Listener ========== // Standalone listener script — no DJ panel code loaded. 'use strict'; // --- State --- let socket = null; let listenerAudioContext = null; let listenerGainNode = null; let listenerAnalyserNode = null; let listenerMediaElementSourceNode = null; let listenerVuMeterRunning = false; // --- Helpers --- function getMp3FallbackUrl() { // Use same-origin so this works behind reverse proxies (e.g. Cloudflare) 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`); } // --- 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; // Keep canvas sized correctly for DPI 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 analyser = listenerAnalyserNode; const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); analyser.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); // Magenta hue (matches Deck B styling) const hue = 280; 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(); } // --- Socket.IO --- function initSocket() { if (socket) return socket; const serverUrl = window.location.origin; console.log(`[LISTENER] Initializing Socket.IO connection to: ${serverUrl}`); socket = io(serverUrl, { transports: ['websocket', 'polling'], reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 10000 }); socket.on('connect', () => { console.log('[OK] Connected to streaming server'); socket.emit('get_listener_count'); }); socket.on('connect_error', (error) => { console.error('[ERROR] Connection error:', error.message); }); socket.on('disconnect', (reason) => { console.log(`[ERROR] Disconnected: ${reason}`); }); socket.on('listener_count', (data) => { const el = document.getElementById('listener-count'); if (el) el.textContent = data.count; }); return socket; } // --- Listener Mode Init --- function initListenerMode() { console.log('[LISTENER] Initializing listener mode (MP3 stream)...'); // Apply glow updateGlowIntensity(30); // Clean up old audio element if it exists (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.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; } window.listenerAudio = null; window.listenerMediaSource = null; window.listenerAudioEnabled = false; } // Create fresh audio element 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'; document.body.appendChild(audio); console.log('[NEW] Created fresh audio element for listener'); // --- Stall Watchdog --- 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) { 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(); }; 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 const enableAudioBtn = document.getElementById('enable-audio-btn'); const statusEl = document.getElementById('connection-status'); if (enableAudioBtn) { enableAudioBtn.style.display = 'flex'; } if (statusEl) { statusEl.textContent = '[INFO] Click "Enable Audio" to start listening (MP3)'; } // Store for later activation window.listenerAudio = audio; window.listenerMediaSource = null; window.listenerAudioEnabled = false; // Initialise socket and join 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) --- async function enableListenerAudio() { console.log('[LISTENER] Enabling audio via user gesture...'); const enableAudioBtn = document.getElementById('enable-audio-btn'); const statusEl = document.getElementById('connection-status'); const audioText = enableAudioBtn ? enableAudioBtn.querySelector('.audio-text') : null; if (audioText) audioText.textContent = 'INITIALIZING...'; try { // 1. Create AudioContext if needed 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'); } // 3. Bridge Audio Element to AudioContext 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); } // Clean single connection chain: media -> analyser -> gain -> destination 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(); } catch (e) { console.warn('Could not connect to AudioContext:', e.message); } } // 4. Prepare and start audio playback if (window.listenerAudio) { window.listenerAudio.muted = false; window.listenerAudio.volume = 1.0; const volEl = document.getElementById('listener-volume'); const volValue = volEl ? parseInt(volEl.value, 10) : 80; setListenerVolume(Number.isFinite(volValue) ? volValue : 80); 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 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'); } } 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; 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 (error.name === 'NotSupportedError') { stashedStatus.textContent = 'MP3 stream failed. Is ffmpeg installed on the server?'; } } } } // --- Volume --- function setListenerVolume(value) { if (listenerGainNode) { listenerGainNode.gain.value = value / 100; } } // --- Boot --- document.addEventListener('DOMContentLoaded', () => { initListenerMode(); });