techdj/listener.js

562 lines
18 KiB
JavaScript

// ========== 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();
});