562 lines
18 KiB
JavaScript
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();
|
|
});
|