diff --git a/listener.css b/listener.css
new file mode 100644
index 0000000..287b161
--- /dev/null
+++ b/listener.css
@@ -0,0 +1,363 @@
+/* ========== TechDJ Listener Stylesheet ========== */
+/* Standalone styles — no DJ panel CSS loaded. */
+
+@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Rajdhani:wght@300;500;700&display=swap');
+
+:root {
+ --bg-dark: #0a0a12;
+ --panel-bg: rgba(20, 20, 30, 0.8);
+ --primary-cyan: #00f3ff;
+ --secondary-magenta: #bc13fe;
+ --text-main: #e0e0e0;
+ --text-dim: #888;
+ --glass-border: 1px solid rgba(255, 255, 255, 0.1);
+ --glow-opacity: 0.3;
+ --glow-spread: 30px;
+ --glow-border: 5px;
+}
+
+* {
+ -webkit-overflow-scrolling: touch;
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ background-color: var(--bg-dark);
+ color: var(--text-main);
+ font-family: 'Rajdhani', sans-serif;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-image:
+ radial-gradient(circle at 10% 20%, rgba(0, 243, 255, 0.15) 0%, transparent 25%),
+ radial-gradient(circle at 90% 80%, rgba(188, 19, 254, 0.15) 0%, transparent 25%),
+ radial-gradient(circle at 50% 50%, rgba(0, 243, 255, 0.05) 0%, transparent 50%);
+}
+
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 1;
+ opacity: var(--glow-opacity, 0.3);
+ transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+/* Listener atmospheric glow */
+body.listener-glow::before {
+ animation: pulse-listener 4s ease-in-out infinite;
+}
+
+@keyframes pulse-listener {
+ 0%, 100% {
+ filter: hue-rotate(0deg) brightness(1.2);
+ box-shadow:
+ 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1.5)),
+ 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1.5)),
+ inset 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1)),
+ inset 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1));
+ }
+ 50% {
+ filter: hue-rotate(15deg) brightness(1.8);
+ box-shadow:
+ 0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 2.2)),
+ 0 0 calc(var(--glow-spread) * 2) rgba(220, 50, 255, calc(var(--glow-opacity) * 2.2)),
+ 0 0 calc(var(--glow-spread) * 4) rgba(0, 243, 255, calc(var(--glow-opacity) * 1)),
+ inset 0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 1.5)),
+ inset 0 0 calc(var(--glow-spread) * 2) rgba(220, 50, 255, calc(var(--glow-opacity) * 1.5));
+ }
+}
+
+/* ========== Listener Mode Layout ========== */
+
+.listener-mode {
+ position: fixed;
+ inset: 0;
+ background: linear-gradient(135deg, #0a0a12 0%, #1a0a1a 100%);
+ z-index: 10;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 40px;
+}
+
+.listener-header {
+ text-align: center;
+ margin-bottom: 40px;
+}
+
+.listener-header h1 {
+ font-family: 'Orbitron', sans-serif;
+ font-size: 3rem;
+ color: var(--secondary-magenta);
+ text-shadow: 0 0 30px var(--secondary-magenta);
+ margin: 0 0 20px 0;
+}
+
+/* ========== Glow Text ========== */
+
+.glow-text {
+ color: #fff;
+ text-shadow: 0 0 10px var(--secondary-magenta), 0 0 20px var(--secondary-magenta);
+ animation: text-glow-pulse 2s infinite ease-in-out;
+}
+
+@keyframes text-glow-pulse {
+ 0%, 100% {
+ opacity: 0.8;
+ text-shadow: 0 0 10px var(--secondary-magenta);
+ }
+ 50% {
+ opacity: 1;
+ text-shadow: 0 0 15px var(--secondary-magenta), 0 0 30px var(--secondary-magenta);
+ }
+}
+
+/* ========== Live Indicator ========== */
+
+.live-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 20px;
+ background: rgba(188, 19, 254, 0.2);
+ border: 2px solid var(--secondary-magenta);
+ border-radius: 25px;
+ font-family: 'Orbitron', sans-serif;
+ font-size: 1.2rem;
+ color: var(--secondary-magenta);
+ box-shadow: 0 0 20px rgba(188, 19, 254, 0.4);
+}
+
+.pulse-dot {
+ width: 12px;
+ height: 12px;
+ background: var(--secondary-magenta);
+ border-radius: 50%;
+ animation: pulse-dot 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse-dot {
+ 0%, 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 50% {
+ transform: scale(1.3);
+ opacity: 0.7;
+ }
+}
+
+/* ========== Listener Content Card ========== */
+
+.listener-content {
+ max-width: 600px;
+ width: 100%;
+ background: rgba(10, 10, 20, 0.8);
+ border: 2px solid var(--secondary-magenta);
+ border-radius: 20px;
+ padding: 40px;
+ box-shadow: 0 0 40px rgba(188, 19, 254, 0.3);
+ backdrop-filter: blur(10px);
+}
+
+/* ========== Visualiser ========== */
+
+#viz-listener {
+ width: 100%;
+ height: 80px;
+ display: block;
+ margin: 20px 0;
+}
+
+/* ========== Now Playing ========== */
+
+.now-playing {
+ text-align: center;
+ font-family: 'Orbitron', sans-serif;
+ font-size: 1.5rem;
+ color: var(--text-main);
+ margin-bottom: 30px;
+ padding: 20px;
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 10px;
+ min-height: 60px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* ========== Volume ========== */
+
+.volume-control {
+ margin-bottom: 20px;
+}
+
+.volume-control label {
+ display: block;
+ margin-bottom: 10px;
+ font-size: 1.1rem;
+ color: var(--text-dim);
+}
+
+.volume-control input[type="range"] {
+ width: 100%;
+ height: 8px;
+ -webkit-appearance: none;
+ appearance: none;
+ background: rgba(188, 19, 254, 0.3);
+ border-radius: 4px;
+ outline: none;
+}
+
+.volume-control input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--secondary-magenta);
+ cursor: pointer;
+ box-shadow: 0 0 10px var(--secondary-magenta);
+}
+
+.volume-control input[type="range"]::-moz-range-thumb {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--secondary-magenta);
+ cursor: pointer;
+ border: none;
+ box-shadow: 0 0 10px var(--secondary-magenta);
+}
+
+/* ========== Connection Status ========== */
+
+.connection-status {
+ text-align: center;
+ font-family: 'Rajdhani', sans-serif;
+ font-size: 0.9rem;
+ color: var(--text-dim);
+ padding: 10px;
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 5px;
+}
+
+.connection-status.connected {
+ color: #00ff00;
+ text-shadow: 0 0 10px #00ff00;
+}
+
+.connection-status.disconnected {
+ color: #ff4444;
+}
+
+/* ========== Enable Audio Button ========== */
+
+.enable-audio-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 30px 40px;
+ margin: 30px 0;
+ background: linear-gradient(145deg, #1a1a1a, #0a0a0a);
+ border: 3px solid var(--secondary-magenta);
+ border-radius: 15px;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ box-shadow: 0 0 30px rgba(188, 19, 254, 0.3);
+ font-family: 'Orbitron', sans-serif;
+}
+
+.enable-audio-btn:hover {
+ background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
+ box-shadow: 0 0 50px rgba(188, 19, 254, 0.6);
+ transform: translateY(-3px);
+ border-color: #ff00ff;
+}
+
+.enable-audio-btn:active {
+ transform: translateY(-1px);
+ box-shadow: 0 0 40px rgba(188, 19, 254, 0.5);
+}
+
+.enable-audio-btn .audio-icon {
+ font-size: 3rem;
+ animation: pulse-icon 2s ease-in-out infinite;
+}
+
+@keyframes pulse-icon {
+ 0%, 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 50% {
+ transform: scale(1.1);
+ opacity: 0.8;
+ }
+}
+
+.enable-audio-btn .audio-text {
+ font-size: 1.5rem;
+ font-weight: bold;
+ color: var(--secondary-magenta);
+ text-shadow: 0 0 10px var(--secondary-magenta);
+}
+
+.enable-audio-btn .audio-subtitle {
+ font-size: 0.9rem;
+ color: var(--text-dim);
+ font-family: 'Rajdhani', sans-serif;
+}
+
+/* ========== Mobile Responsiveness ========== */
+
+@media (max-width: 768px) {
+ .listener-mode {
+ padding: 20px;
+ justify-content: flex-start;
+ padding-top: 60px;
+ }
+
+ .listener-header h1 {
+ font-size: 2.2rem;
+ margin-bottom: 10px;
+ }
+
+ .live-indicator {
+ font-size: 0.9rem;
+ padding: 6px 15px;
+ }
+
+ .listener-content {
+ padding: 25px;
+ margin-top: 10px;
+ border-radius: 15px;
+ }
+
+ .now-playing {
+ font-size: 1.1rem;
+ min-height: 80px;
+ line-height: 1.4;
+ margin-bottom: 20px;
+ }
+
+ .volume-control label {
+ font-size: 0.9rem;
+ }
+
+ #viz-listener {
+ height: 60px !important;
+ margin: 15px 0;
+ }
+}
diff --git a/listener.html b/listener.html
new file mode 100644
index 0000000..359727f
--- /dev/null
+++ b/listener.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ TechDJ Live
+
+
+
+
+
+
+
+
+
Waiting for stream...
+
+
+
+
+
+
+
+
+
+
+
Connecting...
+
+
+
+
+
+
+
+
diff --git a/listener.js b/listener.js
new file mode 100644
index 0000000..d7b7741
--- /dev/null
+++ b/listener.js
@@ -0,0 +1,476 @@
+// ========== 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'],
+ reconnection: true,
+ reconnectionAttempts: 10
+ });
+
+ 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();
+});
diff --git a/server.py b/server.py
index 71af96a..86a1736 100644
--- a/server.py
+++ b/server.py
@@ -269,7 +269,7 @@ if not os.path.exists(MUSIC_FOLDER):
os.makedirs(MUSIC_FOLDER)
# Helper for shared routes
-def setup_shared_routes(app):
+def setup_shared_routes(app, index_file='index.html'):
@app.route('/library.json')
def get_library():
library = []
@@ -359,7 +359,7 @@ def setup_shared_routes(app):
@app.route('/')
def index():
- return send_from_directory('.', 'index.html')
+ return send_from_directory('.', index_file)
@app.route('/upload', methods=['POST'])
def upload_file():
@@ -664,7 +664,7 @@ def dj_audio(data):
listener_app = Flask(__name__, static_folder='.', static_url_path='')
listener_app.config['SECRET_KEY'] = CONFIG_SECRET + '_listener'
listener_app.config['MAX_CONTENT_LENGTH'] = CONFIG_MAX_UPLOAD_MB * 1024 * 1024
-setup_shared_routes(listener_app)
+setup_shared_routes(listener_app, index_file='listener.html')
# Block write/admin endpoints on the listener server
@listener_app.before_request