techdj/listener.js

479 lines
16 KiB
JavaScript

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