Stability fixes: loop end-of-track guard, DJ disconnect grace period, StreamingWorker race condition fix, various audit cleanups

This commit is contained in:
ComputerTech 2026-03-10 18:53:17 +00:00
parent 8c3c2613b1
commit dfccec2b48
10 changed files with 484 additions and 3411 deletions

View File

@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""
Quick memory usage comparison script
Run this to see the difference between web and native versions
"""
import subprocess
import time
def get_process_memory(process_name):
"""Get memory usage of a process in MB"""
try:
result = subprocess.run(
['ps', 'aux'],
capture_output=True,
text=True
)
total_mem = 0
count = 0
for line in result.stdout.split('\n'):
if process_name.lower() in line.lower():
parts = line.split()
if len(parts) > 5:
# RSS is in KB, convert to MB
mem_kb = float(parts[5])
total_mem += mem_kb / 1024
count += 1
return total_mem, count
except Exception as e:
return 0, 0
def main():
print("=" * 60)
print("TechDJ Memory Usage Comparison")
print("=" * 60)
print()
# Check Chrome
chrome_mem, chrome_procs = get_process_memory('chrome')
if chrome_mem > 0:
print(f"[CHROME] Chrome (Web Panel):")
print(f" Total Memory: {chrome_mem:.1f} MB")
print(f" Processes: {chrome_procs}")
print()
else:
print("[CHROME] Chrome: Not running")
print()
# Check PyQt6
qt_mem, qt_procs = get_process_memory('techdj_qt')
if qt_mem > 0:
print(f"[PYQT6] PyQt6 Native App:")
print(f" Total Memory: {qt_mem:.1f} MB")
print(f" Processes: {qt_procs}")
print()
else:
print("[PYQT6] PyQt6 Native App: Not running")
print()
# Comparison
if chrome_mem > 0 and qt_mem > 0:
savings = chrome_mem - qt_mem
percent = (savings / chrome_mem) * 100
print("=" * 60)
print("[STATS] Comparison:")
print(f" Memory Saved: {savings:.1f} MB ({percent:.1f}%)")
print()
# Visual bar chart
max_mem = max(chrome_mem, qt_mem)
chrome_bar = '#' * int((chrome_mem / max_mem) * 40)
qt_bar = '#' * int((qt_mem / max_mem) * 40)
print(" Chrome: " + chrome_bar + f" {chrome_mem:.0f}MB")
print(" PyQt6: " + qt_bar + f" {qt_mem:.0f}MB")
print()
if percent > 50:
print(f" [OK] PyQt6 uses {percent:.0f}% less memory!")
elif percent > 25:
print(f" [OK] PyQt6 uses {percent:.0f}% less memory")
else:
print(f" PyQt6 uses {percent:.0f}% less memory")
print("=" * 60)
print()
print("Tip: Run both versions and execute this script to compare!")
print()
if __name__ == '__main__':
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

View File

@ -1,162 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Edge Glow Test</title>
<style>
body {
margin: 0;
padding: 0;
background: #0a0a14;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: Arial, sans-serif;
}
/* EDGE GLOW - EXACT COPY FROM style.css lines 28-61 */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 99999;
border: 3px solid rgba(80, 80, 80, 0.3);
}
body.playing-A::before {
border: 15px solid #00f3ff;
box-shadow:
0 0 80px rgba(0, 243, 255, 1),
inset 0 0 80px rgba(0, 243, 255, 0.8);
animation: pulse-cyan 3s ease-in-out infinite;
}
body.playing-B::before {
border: 15px solid #bc13fe;
box-shadow:
0 0 80px rgba(188, 19, 254, 1),
inset 0 0 80px rgba(188, 19, 254, 0.8);
animation: pulse-magenta 3s ease-in-out infinite;
}
body.playing-A.playing-B::before {
border: 15px solid #00f3ff;
box-shadow:
0 0 80px rgba(0, 243, 255, 1),
0 0 120px rgba(188, 19, 254, 1),
inset 0 0 80px rgba(0, 243, 255, 0.6),
inset 0 0 120px rgba(188, 19, 254, 0.6);
animation: pulse-both 3s ease-in-out infinite;
}
@keyframes pulse-cyan {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes pulse-magenta {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes pulse-both {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.controls {
text-align: center;
z-index: 100000;
}
button {
padding: 20px 40px;
margin: 10px;
font-size: 18px;
cursor: pointer;
border: 2px solid #fff;
background: rgba(255, 255, 255, 0.1);
color: #fff;
border-radius: 8px;
}
button:hover {
background: rgba(255, 255, 255, 0.2);
}
.status {
color: #fff;
margin-top: 20px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="controls">
<h1 style="color: #fff;">Edge Glow Test</h1>
<button onclick="testDeckA()">Test Deck A (Cyan)</button>
<button onclick="testDeckB()">Test Deck B (Magenta)</button>
<button onclick="testBoth()">Test Both</button>
<button onclick="testOff()">Turn Off</button>
<div class="status" id="status">Status: No glow</div>
</div>
<script>
function testDeckA() {
document.body.className = 'playing-A';
document.getElementById('status').textContent = 'Status: Deck A (Cyan) - body class: ' + document.body.className;
console.log('Body classes:', document.body.classList.toString());
}
function testDeckB() {
document.body.className = 'playing-B';
document.getElementById('status').textContent = 'Status: Deck B (Magenta) - body class: ' + document.body.className;
console.log('Body classes:', document.body.classList.toString());
}
function testBoth() {
document.body.className = 'playing-A playing-B';
document.getElementById('status').textContent = 'Status: Both (Cyan + Magenta) - body class: ' + document.body.className;
console.log('Body classes:', document.body.classList.toString());
}
function testOff() {
document.body.className = '';
document.getElementById('status').textContent = 'Status: No glow - body class: (empty)';
console.log('Body classes:', document.body.classList.toString());
}
// Auto-test on load
setTimeout(() => {
console.log('=== EDGE GLOW TEST ===');
console.log('If you see a CYAN border around the screen, the glow is working!');
testDeckA();
}, 500);
</script>
</body>
</html>

View File

@ -268,6 +268,7 @@ body.listener-glow::before {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
width: 100%;
padding: 30px 40px; padding: 30px 40px;
margin: 30px 0; margin: 30px 0;
background: linear-gradient(145deg, #1a1a1a, #0a0a0a); background: linear-gradient(145deg, #1a1a1a, #0a0a0a);
@ -361,3 +362,19 @@ body.listener-glow::before {
margin: 15px 0; margin: 15px 0;
} }
} }
/* ========== Listener Count Badge ========== */
.listener-count-badge {
font-family: 'Rajdhani', sans-serif;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
margin-left: 8px;
padding-left: 10px;
border-left: 1px solid rgba(188, 19, 254, 0.4);
}
.listener-count-badge #listener-count {
color: var(--secondary-magenta, #bc13fe);
font-weight: 700;
}

View File

@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>TechDJ Live</title> <title>TechDJ Live</title>
<link rel="stylesheet" href="listener.css?v=1.0"> <link rel="stylesheet" href="listener.css?v=2.0">
</head> </head>
<body class="listener-glow listening-active"> <body class="listener-glow listening-active">
@ -16,6 +16,7 @@
<div class="live-indicator"> <div class="live-indicator">
<span class="pulse-dot"></span> <span class="pulse-dot"></span>
<span>LIVE</span> <span>LIVE</span>
<span class="listener-count-badge"><span id="listener-count">0</span> listening</span>
</div> </div>
</div> </div>
<div class="listener-content"> <div class="listener-content">
@ -25,7 +26,7 @@
<!-- Enable Audio Button (shown when autoplay is blocked) --> <!-- Enable Audio Button (shown when autoplay is blocked) -->
<button class="enable-audio-btn" id="enable-audio-btn" onclick="enableListenerAudio()"> <button class="enable-audio-btn" id="enable-audio-btn" onclick="enableListenerAudio()">
<span class="audio-icon"></span> <span class="audio-icon">🎧</span>
<span class="audio-text">ENABLE AUDIO</span> <span class="audio-text">ENABLE AUDIO</span>
<span class="audio-subtitle">Click to start listening</span> <span class="audio-subtitle">Click to start listening</span>
</button> </button>
@ -40,7 +41,7 @@
</div> </div>
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script src="listener.js?v=1.0"></script> <script src="listener.js?v=2.0"></script>
</body> </body>
</html> </html>

View File

@ -1,9 +1,17 @@
// ========== TechDJ Listener ========== // ========== TechDJ Listener ==========
// Standalone listener script — no DJ panel code loaded. // 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'; 'use strict';
// --- State --- // --- Core State ---
let socket = null; let socket = null;
let listenerAudioContext = null; let listenerAudioContext = null;
let listenerGainNode = null; let listenerGainNode = null;
@ -11,10 +19,21 @@ let listenerAnalyserNode = null;
let listenerMediaElementSourceNode = null; let listenerMediaElementSourceNode = null;
let listenerVuMeterRunning = false; 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
// Stall watchdog state
let stallWatchdogInterval = null;
let lastWatchdogTime = 0;
let stallCount = 0;
// --- Helpers --- // --- Helpers ---
function getMp3FallbackUrl() { function getMp3StreamUrl() {
// Use same-origin so this works behind reverse proxies (e.g. Cloudflare)
return `${window.location.origin}/stream.mp3`; return `${window.location.origin}/stream.mp3`;
} }
@ -25,6 +44,56 @@ function updateGlowIntensity(val) {
document.documentElement.style.setProperty('--glow-spread', `${spread}px`); 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;
}
function updateListenerCount(count) {
const el = document.getElementById('listener-count');
if (el) el.textContent = count;
}
// --- 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 --- // --- VU Meter ---
function startListenerVUMeter() { function startListenerVUMeter() {
@ -41,7 +110,7 @@ function startListenerVUMeter() {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
// Keep canvas sized correctly for DPI // DPI-aware canvas sizing
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const targetW = Math.max(1, Math.floor(rect.width * dpr)); const targetW = Math.max(1, Math.floor(rect.width * dpr));
@ -51,10 +120,9 @@ function startListenerVUMeter() {
canvas.height = targetH; canvas.height = targetH;
} }
const analyser = listenerAnalyserNode; const bufferLength = listenerAnalyserNode.frequencyBinCount;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength); const dataArray = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(dataArray); listenerAnalyserNode.getByteFrequencyData(dataArray);
const width = canvas.width; const width = canvas.width;
const height = canvas.height; const height = canvas.height;
@ -64,8 +132,7 @@ function startListenerVUMeter() {
ctx.fillStyle = '#0a0a12'; ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
// Magenta hue (matches Deck B styling) const hue = 280; // Magenta
const hue = 280;
for (let i = 0; i < barCount; i++) { for (let i = 0; i < barCount; i++) {
const freqIndex = Math.floor(Math.pow(i / barCount, 1.5) * bufferLength); const freqIndex = Math.floor(Math.pow(i / barCount, 1.5) * bufferLength);
const value = (dataArray[freqIndex] || 0) / 255; const value = (dataArray[freqIndex] || 0) / 255;
@ -84,13 +151,101 @@ function startListenerVUMeter() {
draw(); 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;
}
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(() => {
console.log('[OK] Stream playback started');
resetReconnectBackoff();
updateStatus('Audio Active — Enjoy the stream!', true);
startStallWatchdog();
startListenerVUMeter();
})
.catch(e => {
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 --- // --- Socket.IO ---
function initSocket() { function initSocket() {
if (socket) return socket; if (socket) return socket;
const serverUrl = window.location.origin; const serverUrl = window.location.origin;
console.log(`[LISTENER] Initializing Socket.IO connection to: ${serverUrl}`); console.log(`[SOCKET] Connecting to ${serverUrl}`);
socket = io(serverUrl, { socket = io(serverUrl, {
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
@ -101,48 +256,88 @@ function initSocket() {
}); });
socket.on('connect', () => { socket.on('connect', () => {
console.log('[OK] Connected to streaming server'); console.log('[SOCKET] Connected');
socket.emit('join_listener');
socket.emit('get_listener_count'); socket.emit('get_listener_count');
if (window.listenerAudioEnabled) {
updateStatus('Connected', true);
}
}); });
socket.on('connect_error', (error) => { socket.on('connect_error', (error) => {
console.error('[ERROR] Connection error:', error.message); console.error('[SOCKET] Connection error:', error.message);
}); });
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
console.log(`[ERROR] Disconnected: ${reason}`); console.warn(`[SOCKET] Disconnected: ${reason}`);
if (window.listenerAudioEnabled) {
updateStatus('Server disconnected — reconnecting...', false);
}
}); });
socket.on('listener_count', (data) => { socket.on('listener_count', (data) => {
const el = document.getElementById('listener-count'); updateListenerCount(data.count);
if (el) el.textContent = data.count; });
// --- 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) {
// Small delay to let the transcoder produce initial data
resetReconnectBackoff();
setTimeout(() => connectStream(), 800);
}
});
socket.on('broadcast_stopped', () => {
console.log('[EVENT] broadcast_stopped');
broadcastActive = false;
updateNowPlaying('Stream ended');
handleBroadcastOffline();
}); });
return socket; return socket;
} }
// --- Listener Mode Init --- /** Clean up when broadcast goes offline. */
function handleBroadcastOffline() {
cancelScheduledReconnect();
stopStallWatchdog();
function initListenerMode() { if (window.listenerAudioEnabled) {
console.log('[LISTENER] Initializing listener mode (MP3 stream)...'); updateStatus('Stream ended — waiting for DJ...', false);
}
// Apply glow // Pause audio (keep the element alive for when broadcast returns)
updateGlowIntensity(30); if (window.listenerAudio && !window.listenerAudio.paused) {
// 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(); 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);
} }
// --- Audio Graph Cleanup ---
function cleanupAudioGraph() {
if (listenerMediaElementSourceNode) { if (listenerMediaElementSourceNode) {
try { listenerMediaElementSourceNode.disconnect(); } catch (_) { } try { listenerMediaElementSourceNode.disconnect(); } catch (_) { }
listenerMediaElementSourceNode = null; listenerMediaElementSourceNode = null;
@ -155,310 +350,181 @@ function initListenerMode() {
try { listenerGainNode.disconnect(); } catch (_) { } try { listenerGainNode.disconnect(); } catch (_) { }
listenerGainNode = null; listenerGainNode = null;
} }
window.listenerAudio = null;
window.listenerMediaSource = null;
window.listenerAudioEnabled = false;
} }
// Create fresh audio element // --- 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'); const audio = document.createElement('audio');
audio.autoplay = false; audio.autoplay = false;
audio.muted = false; audio.muted = false;
audio.controls = false; audio.controls = false;
audio.playsInline = true; audio.playsInline = true;
audio.setAttribute('playsinline', ''); audio.setAttribute('playsinline', '');
audio.style.display = 'none';
audio.crossOrigin = 'anonymous'; audio.crossOrigin = 'anonymous';
audio.preload = 'none';
audio.style.display = 'none';
document.body.appendChild(audio); document.body.appendChild(audio);
console.log('[NEW] Created fresh audio element for listener');
// --- Stall Watchdog --- // --- Audio element event handlers ---
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 = () => { audio.onerror = () => {
if (!window.listenerAudioEnabled) return; if (!window.listenerAudioEnabled) return;
console.error('[ERROR] Audio stream error!'); const err = audio.error;
reconnectStream(); console.error(`[AUDIO] Error: code=${err?.code}, msg=${err?.message}`);
if (broadcastActive) {
scheduleReconnect();
} else {
updateStatus('Stream offline', false);
}
}; };
audio.onplay = () => { audio.onplay = () => {
console.log('[PLAY] Stream playing'); startStallWatchdog();
startWatchdog(); if (window.listenerAudioEnabled && broadcastActive) {
const statusEl = document.getElementById('connection-status'); updateStatus('Audio Active — Enjoy the stream!', true);
if (statusEl) statusEl.classList.add('glow-text'); }
}; };
audio.onpause = () => { audio.onpause = () => {
console.log('[PAUSE] Stream paused'); stopStallWatchdog();
stopWatchdog();
}; };
// Show enable audio button audio.onwaiting = () => {
const enableAudioBtn = document.getElementById('enable-audio-btn'); if (window.listenerAudioEnabled) {
const statusEl = document.getElementById('connection-status'); updateStatus('Buffering...', false);
if (enableAudioBtn) {
enableAudioBtn.style.display = 'flex';
}
if (statusEl) {
statusEl.textContent = '[INFO] Click "Enable Audio" to start listening (MP3)';
} }
};
// Store for later activation audio.onplaying = () => {
if (window.listenerAudioEnabled && broadcastActive) {
updateStatus('Audio Active — Enjoy the stream!', true);
}
};
// Store globally
window.listenerAudio = audio; window.listenerAudio = audio;
window.listenerMediaSource = null;
window.listenerAudioEnabled = false; window.listenerAudioEnabled = false;
// Initialise socket and join // 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(); 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) --- // --- Enable Audio (requires user gesture) ---
async function enableListenerAudio() { async function enableListenerAudio() {
console.log('[LISTENER] Enabling audio via user gesture...'); console.log('[ENABLE] User clicked Enable Audio');
const enableAudioBtn = document.getElementById('enable-audio-btn'); const btn = document.getElementById('enable-audio-btn');
const statusEl = document.getElementById('connection-status'); const audioText = btn?.querySelector('.audio-text');
const audioText = enableAudioBtn ? enableAudioBtn.querySelector('.audio-text') : null; const audioSubtitle = btn?.querySelector('.audio-subtitle');
if (audioText) audioText.textContent = 'INITIALIZING...'; if (audioText) audioText.textContent = 'INITIALIZING...';
if (audioSubtitle) audioSubtitle.textContent = '';
try { try {
// 1. Create AudioContext if needed // 1. Create/resume AudioContext (must happen inside a user gesture)
if (!listenerAudioContext) { if (!listenerAudioContext) {
listenerAudioContext = new (window.AudioContext || window.webkitAudioContext)(); listenerAudioContext = new (window.AudioContext || window.webkitAudioContext)();
} }
// 2. Resume audio context (CRITICAL for Chrome/Safari)
if (listenerAudioContext.state === 'suspended') { if (listenerAudioContext.state === 'suspended') {
await listenerAudioContext.resume(); await listenerAudioContext.resume();
console.log('[OK] Audio context resumed');
} }
console.log('[OK] AudioContext active');
// 3. Bridge Audio Element to AudioContext // 2. Build audio graph: source → analyser → gain → speakers
if (window.listenerAudio) { if (window.listenerAudio) {
try { try {
if (!listenerGainNode) { if (!listenerGainNode) {
listenerGainNode = listenerAudioContext.createGain(); listenerGainNode = listenerAudioContext.createGain();
listenerGainNode.gain.value = 0.8;
listenerGainNode.connect(listenerAudioContext.destination); listenerGainNode.connect(listenerAudioContext.destination);
} }
if (!listenerAnalyserNode) { if (!listenerAnalyserNode) {
listenerAnalyserNode = listenerAudioContext.createAnalyser(); listenerAnalyserNode = listenerAudioContext.createAnalyser();
listenerAnalyserNode.fftSize = 256; listenerAnalyserNode.fftSize = 256;
} }
if (!listenerMediaElementSourceNode) { if (!listenerMediaElementSourceNode) {
listenerMediaElementSourceNode = listenerAudioContext.createMediaElementSource(window.listenerAudio); listenerMediaElementSourceNode =
listenerAudioContext.createMediaElementSource(window.listenerAudio);
} }
// Clean single connection chain: media -> analyser -> gain -> destination // Wire up (disconnect first to avoid double-connections)
try { listenerMediaElementSourceNode.disconnect(); } catch (_) { } try { listenerMediaElementSourceNode.disconnect(); } catch (_) { }
try { listenerAnalyserNode.disconnect(); } catch (_) { } try { listenerAnalyserNode.disconnect(); } catch (_) { }
listenerMediaElementSourceNode.connect(listenerAnalyserNode); listenerMediaElementSourceNode.connect(listenerAnalyserNode);
listenerAnalyserNode.connect(listenerGainNode); listenerAnalyserNode.connect(listenerGainNode);
console.log('[OK] Audio graph connected');
window.listenerAudio._connectedToContext = true;
console.log('[OK] Connected audio element to AudioContext (with analyser)');
startListenerVUMeter();
} catch (e) { } catch (e) {
console.warn('Could not connect to AudioContext:', e.message); // Non-fatal: audio still plays, just no VU meter
console.warn('[WARN] Audio graph setup failed:', e.message);
} }
} }
// 4. Prepare and start audio playback // 3. Apply volume
if (window.listenerAudio) {
window.listenerAudio.muted = false; window.listenerAudio.muted = false;
window.listenerAudio.volume = 1.0; window.listenerAudio.volume = 1.0;
const volEl = document.getElementById('listener-volume'); const volEl = document.getElementById('listener-volume');
const volValue = volEl ? parseInt(volEl.value, 10) : 80; setListenerVolume(volEl ? parseInt(volEl.value, 10) || 80 : 80);
setListenerVolume(Number.isFinite(volValue) ? volValue : 80);
const hasBufferedData = () => {
return window.listenerAudio.buffered && window.listenerAudio.buffered.length > 0;
};
// 4. Mark enabled
window.listenerAudioEnabled = true; window.listenerAudioEnabled = true;
if (audioText) audioText.textContent = 'STARTING...'; // 5. Hide button with a smooth transition
console.log('[PLAY] Attempting to play audio...'); if (btn) {
btn.style.opacity = '0';
const playTimeout = setTimeout(() => { btn.style.pointerEvents = 'none';
if (!hasBufferedData()) { setTimeout(() => { btn.style.display = 'none'; }, 300);
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 { // 6. Start streaming if broadcast is active, otherwise wait
await playPromise; if (broadcastActive) {
clearTimeout(playTimeout); if (audioText) audioText.textContent = 'CONNECTING...';
console.log('[OK] Audio playback started successfully'); connectStream();
} catch (e) { } else {
clearTimeout(playTimeout); updateStatus('Audio enabled — waiting for stream to start...', false);
throw e; updateNowPlaying('Waiting for DJ to start streaming...');
}
}
// 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) { } catch (error) {
console.error('[ERROR] Failed to enable audio:', error); console.error('[ERROR] Enable audio failed:', error);
const stashedBtn = document.getElementById('enable-audio-btn'); window.listenerAudioEnabled = false;
const stashedStatus = document.getElementById('connection-status');
const aText = stashedBtn ? stashedBtn.querySelector('.audio-text') : null; if (audioText) audioText.textContent = 'TAP TO RETRY';
if (audioSubtitle) audioSubtitle.textContent = '';
if (btn) btn.style.pointerEvents = 'auto';
if (aText) aText.textContent = 'RETRY ENABLE';
if (stashedStatus) {
let errorMsg = error.name + ': ' + error.message;
if (error.name === 'NotAllowedError') { if (error.name === 'NotAllowedError') {
errorMsg = 'Browser blocked audio (NotAllowedError). Check permissions.'; updateStatus('Browser blocked audio — tap the button again.', false);
} else if (error.name === 'NotSupportedError') { } else {
errorMsg = 'MP3 stream not supported or unavailable (NotSupportedError).'; updateStatus(`Error: ${error.message}`, false);
}
stashedStatus.textContent = errorMsg;
if (error.name === 'NotSupportedError') {
stashedStatus.textContent = 'MP3 stream failed. Is ffmpeg installed on the server?';
}
} }
} }
} }
@ -467,7 +533,7 @@ async function enableListenerAudio() {
function setListenerVolume(value) { function setListenerVolume(value) {
if (listenerGainNode) { if (listenerGainNode) {
listenerGainNode.gain.value = value / 100; listenerGainNode.gain.value = Math.max(0, Math.min(1, value / 100));
} }
} }

View File

@ -470,13 +470,6 @@ function switchQueueTab() {
function toggleMobileLibrary() {
// This is now handled by tabs, but keep for compatibility if needed
const lib = document.querySelector('.library-section');
lib.classList.toggle('active');
vibrate(20);
}
// Mobile Haptic Helper // Mobile Haptic Helper
function vibrate(ms) { function vibrate(ms) {
if (navigator.vibrate) { if (navigator.vibrate) {
@ -608,9 +601,13 @@ function drawWaveform(id) {
const data = decks[id].waveformData; const data = decks[id].waveformData;
if (!data) return; if (!data) return;
ctx.clearRect(0, 0, canvas.width, canvas.height); // Use logical (CSS) dimensions — the canvas context has ctx.scale(dpr)
const width = canvas.width; // applied in initSystem, so coordinates must be in logical pixels.
const height = canvas.height; const dpr = window.devicePixelRatio || 1;
const width = canvas.width / dpr;
const height = canvas.height / dpr;
ctx.clearRect(0, 0, width, height);
const barWidth = width / data.length; const barWidth = width / data.length;
data.forEach((val, i) => { data.forEach((val, i) => {
@ -804,8 +801,6 @@ function playDeck(id) {
if (deckEl) deckEl.classList.add('playing'); if (deckEl) deckEl.classList.add('playing');
document.body.classList.add('playing-' + id); document.body.classList.add('playing-' + id);
if (audioCtx.state === 'suspended') { if (audioCtx.state === 'suspended') {
console.log(`[Deck ${id}] Resuming suspended AudioContext`); console.log(`[Deck ${id}] Resuming suspended AudioContext`);
audioCtx.resume(); audioCtx.resume();
@ -824,8 +819,6 @@ function playDeck(id) {
const deckEl = document.getElementById('deck-' + id); const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.remove('playing'); if (deckEl) deckEl.classList.remove('playing');
document.body.classList.remove('playing-' + id); document.body.classList.remove('playing-' + id);
alert(`Playback error: ${error.message}`); alert(`Playback error: ${error.message}`);
} }
} else { } else {
@ -1394,13 +1387,6 @@ function updateLibraryHighlighting() {
}); });
} }
// Utility function no longer needed but kept for future use
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function filterLibrary() { function filterLibrary() {
const query = document.getElementById('lib-search').value.toLowerCase(); const query = document.getElementById('lib-search').value.toLowerCase();
const filtered = allSongs.filter(s => s.title.toLowerCase().includes(query)); const filtered = allSongs.filter(s => s.title.toLowerCase().includes(query));
@ -1411,7 +1397,6 @@ function refreshLibrary() {
fetchLibrary(); fetchLibrary();
} }
async function loadFromServer(id, url, title) { async function loadFromServer(id, url, title) {
const d = document.getElementById('display-' + id); const d = document.getElementById('display-' + id);
d.innerText = '[WAIT] LOADING...'; d.innerText = '[WAIT] LOADING...';
@ -1839,9 +1824,7 @@ function initSocket() {
if (socket) return socket; if (socket) return socket;
const serverUrl = window.location.origin; const serverUrl = window.location.origin;
console.log(`CONNECT Initializing Socket.IO connection to: ${serverUrl}`); console.log(`[SOCKET] Connecting to ${serverUrl}`);
console.log(` Protocol: ${window.location.protocol}`);
console.log(` Host: ${window.location.host}`);
socket = io(serverUrl, { socket = io(serverUrl, {
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
@ -1853,21 +1836,15 @@ function initSocket() {
socket.on('connect', () => { socket.on('connect', () => {
console.log('[OK] Connected to streaming server'); console.log('[OK] Connected to streaming server');
console.log(` Socket ID: ${socket.id}`);
console.log(` Transport: ${socket.io.engine.transport.name}`);
// Get initial listener count soon as we connect
socket.emit('get_listener_count'); socket.emit('get_listener_count');
}); });
socket.on('connect_error', (error) => { socket.on('connect_error', (error) => {
console.error('[ERROR] Connection error:', error.message); console.error('[ERROR] Connection error:', error.message);
console.error(' Make sure server is running on', serverUrl);
}); });
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
console.log('[ERROR] Disconnected from streaming server'); console.warn(`[WARN] Disconnected: ${reason}`);
console.log(` Reason: ${reason}`);
}); });
socket.on('listener_count', (data) => { socket.on('listener_count', (data) => {
@ -1876,7 +1853,7 @@ function initSocket() {
}); });
socket.on('broadcast_started', () => { socket.on('broadcast_started', () => {
console.log('Broadcast started notification received'); console.log('[EVENT] broadcast_started');
// Update relay UI if it's a relay // Update relay UI if it's a relay
const relayStatus = document.getElementById('relay-status'); const relayStatus = document.getElementById('relay-status');
if (relayStatus && relayStatus.textContent.includes('Connecting')) { if (relayStatus && relayStatus.textContent.includes('Connecting')) {
@ -1886,7 +1863,7 @@ function initSocket() {
}); });
socket.on('broadcast_stopped', () => { socket.on('broadcast_stopped', () => {
console.log('STOP Broadcast stopped notification received'); console.log('[EVENT] broadcast_stopped');
// Reset relay UI if it was active // Reset relay UI if it was active
const startRelayBtn = document.getElementById('start-relay-btn'); const startRelayBtn = document.getElementById('start-relay-btn');
const stopRelayBtn = document.getElementById('stop-relay-btn'); const stopRelayBtn = document.getElementById('stop-relay-btn');
@ -2025,7 +2002,7 @@ function startBroadcast() {
isBroadcasting = true; isBroadcasting = true;
document.getElementById('broadcast-btn').classList.add('active'); document.getElementById('broadcast-btn').classList.add('active');
document.getElementById('broadcast-text').textContent = 'STOP BROADCAST'; document.getElementById('broadcast-text').textContent = 'STOP BROADCAST';
document.getElementById('broadcast-status').textContent = '[OFFLINE] LIVE'; document.getElementById('broadcast-status').textContent = 'LIVE';
document.getElementById('broadcast-status').classList.add('live'); document.getElementById('broadcast-status').classList.add('live');
if (!socket) initSocket(); if (!socket) initSocket();
@ -2228,7 +2205,7 @@ function startBroadcast() {
isBroadcasting = true; isBroadcasting = true;
document.getElementById('broadcast-btn').classList.add('active'); document.getElementById('broadcast-btn').classList.add('active');
document.getElementById('broadcast-text').textContent = 'STOP BROADCAST'; document.getElementById('broadcast-text').textContent = 'STOP BROADCAST';
document.getElementById('broadcast-status').textContent = '[OFFLINE] LIVE'; document.getElementById('broadcast-status').textContent = 'LIVE';
document.getElementById('broadcast-status').classList.add('live'); document.getElementById('broadcast-status').classList.add('live');
// Notify server that broadcast is active (listeners use MP3 stream) // Notify server that broadcast is active (listeners use MP3 stream)
@ -2259,7 +2236,7 @@ function startBroadcast() {
// Stop broadcasting // Stop broadcasting
function stopBroadcast() { function stopBroadcast() {
console.log('STOP Stopping broadcast...'); console.log('[BROADCAST] Stopping...');
if (SERVER_SIDE_AUDIO) { if (SERVER_SIDE_AUDIO) {
isBroadcasting = false; isBroadcasting = false;
@ -2313,7 +2290,6 @@ function stopBroadcast() {
console.log('[OK] Broadcast stopped'); console.log('[OK] Broadcast stopped');
} }
// Restart broadcasting (for auto-recovery) // Restart broadcasting (for auto-recovery)
function restartBroadcast() { function restartBroadcast() {
console.log('Restarting broadcast...'); console.log('Restarting broadcast...');
@ -2367,19 +2343,31 @@ function restartBroadcast() {
// Copy stream URL to clipboard // Copy stream URL to clipboard
function copyStreamUrl(evt) { function copyStreamUrl(evt) {
const urlInput = document.getElementById('stream-url'); const urlInput = document.getElementById('stream-url');
urlInput.select(); const text = urlInput.value;
urlInput.setSelectionRange(0, 99999); // For mobile const btn = evt?.target;
const showFeedback = (success) => {
if (!btn) return;
const originalText = btn.textContent;
btn.textContent = success ? 'OK' : 'FAIL';
setTimeout(() => { btn.textContent = originalText; }, 2000);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text)
.then(() => showFeedback(true))
.catch(() => showFeedback(false));
} else {
// Fallback for older browsers
urlInput.select();
urlInput.setSelectionRange(0, 99999);
try { try {
document.execCommand('copy'); document.execCommand('copy');
const btn = evt?.target; showFeedback(true);
const originalText = btn.textContent;
btn.textContent = 'OK';
setTimeout(() => {
btn.textContent = originalText;
}, 2000);
} catch (err) { } catch (err) {
console.error('Failed to copy:', err); console.error('Failed to copy:', err);
showFeedback(false);
}
} }
} }
@ -2416,7 +2404,10 @@ function monitorTrackEnd() {
const remaining = decks[id].duration - current; const remaining = decks[id].duration - current;
// If end reached (with 0.5s buffer for safety) // If end reached (with 0.5s buffer for safety)
if (remaining <= 0.5) { // Skip if a loop is active — the Web Audio API handles looping natively
// and lastAnchorPosition is not updated on each native loop repeat, so
// the monotonically-growing `current` would incorrectly trigger end-of-track.
if (remaining <= 0.5 && !decks[id].loopActive) {
// During broadcast, still handle auto-play/queue to avoid dead air // During broadcast, still handle auto-play/queue to avoid dead air
if (isBroadcasting) { if (isBroadcasting) {
console.log(`Track ending during broadcast on Deck ${id}`); console.log(`Track ending during broadcast on Deck ${id}`);
@ -2482,6 +2473,7 @@ function monitorTrackEnd() {
}, 500); // Check every 0.5s }, 500); // Check every 0.5s
} }
monitorTrackEnd(); monitorTrackEnd();
// Reset Deck to Default Settings // Reset Deck to Default Settings
function resetDeck(id) { function resetDeck(id) {
vibrate(20); vibrate(20);
@ -2555,7 +2547,6 @@ function resetDeck(id) {
console.log(`[OK] Deck ${id} reset complete!`); console.log(`[OK] Deck ${id} reset complete!`);
// Visual feedback // Visual feedback
const resetBtn = document.querySelector(`#deck-${id} .reset-btn`); const resetBtn = document.querySelector(`#deck-${id} .reset-btn`);
if (resetBtn) { if (resetBtn) {
@ -2966,8 +2957,12 @@ function renderKeyboardMappings() {
<span class="key-display">${formatKeyName(key)}</span> <span class="key-display">${formatKeyName(key)}</span>
<span class="key-arrow">RIGHT</span> <span class="key-arrow">RIGHT</span>
<span class="action-label">${mapping.label}</span> <span class="action-label">${mapping.label}</span>
<button class="key-reassign-btn" onclick="reassignKey('${key}', event)">Change</button>
`; `;
const changeBtn = document.createElement('button');
changeBtn.className = 'key-reassign-btn';
changeBtn.textContent = 'Change';
changeBtn.addEventListener('click', (e) => reassignKey(key, e));
item.appendChild(changeBtn);
list.appendChild(item); list.appendChild(item);
}); });
} }

View File

@ -13,7 +13,6 @@ import collections
from flask import Flask, send_from_directory, jsonify, request, session, Response, stream_with_context, abort from flask import Flask, send_from_directory, jsonify, request, session, Response, stream_with_context, abort
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv() load_dotenv()
@ -47,23 +46,27 @@ CONFIG_DEBUG = bool(CONFIG.get('debug', False))
DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip() DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip()
DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD) DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD)
# Relay State # Broadcast State
broadcast_state = { broadcast_state = {
'active': False, 'active': False,
} }
listener_sids = set() listener_sids = set()
dj_sids = set() dj_sids = set()
# Grace-period greenlet: auto-stop broadcast if DJ doesn't reconnect in time
_dj_grace_greenlet = None
DJ_GRACE_PERIOD_SECS = 20 # seconds to wait before auto-stopping
# === Optional MP3 fallback stream (server-side transcoding) === # === Optional MP3 fallback stream (server-side transcoding) ===
_ffmpeg_proc = None _ffmpeg_proc = None
_ffmpeg_in_q = queue.Queue(maxsize=20) # Optimized for low-latency live streaming _ffmpeg_in_q = queue.Queue(maxsize=20)
_current_bitrate = (CONFIG.get('stream_bitrate') or '192k').strip() _current_bitrate = (CONFIG.get('stream_bitrate') or '192k').strip()
_mp3_clients = set() # set[queue.Queue] _mp3_clients = set() # set[queue.Queue]
_mp3_lock = threading.Lock() _mp3_lock = threading.Lock()
_transcoder_bytes_out = 0 _transcoder_bytes_out = 0
_transcoder_last_error = None _transcoder_last_error = None
_last_audio_chunk_ts = 0.0 _last_audio_chunk_ts = 0.0
_mp3_preroll = collections.deque(maxlen=512) # Larger pre-roll (~512KB) _mp3_preroll = collections.deque(maxlen=512)
def _start_transcoder_if_needed(is_mp3_input=False): def _start_transcoder_if_needed(is_mp3_input=False):
@ -254,7 +257,6 @@ def _load_settings():
return {} return {}
SETTINGS = _load_settings() SETTINGS = _load_settings()
# Config.json music_folder overrides settings.json if set
_config_music = (CONFIG.get('music_folder') or '').strip() _config_music = (CONFIG.get('music_folder') or '').strip()
MUSIC_FOLDER = _config_music or SETTINGS.get('library', {}).get('music_folder', 'music') MUSIC_FOLDER = _config_music or SETTINGS.get('library', {}).get('music_folder', 'music')
@ -275,8 +277,6 @@ def setup_shared_routes(app, index_file='index.html'):
library = [] library = []
global MUSIC_FOLDER global MUSIC_FOLDER
if os.path.exists(MUSIC_FOLDER): if os.path.exists(MUSIC_FOLDER):
# Recursively find music files if desired, or stay top-level.
# The prompt says "choose which folder", so maybe top-level of that folder is fine.
for root, dirs, files in os.walk(MUSIC_FOLDER): for root, dirs, files in os.walk(MUSIC_FOLDER):
for filename in sorted(files): for filename in sorted(files):
if filename.lower().endswith(('.mp3', '.m4a', '.wav', '.flac', '.ogg')): if filename.lower().endswith(('.mp3', '.m4a', '.wav', '.flac', '.ogg')):
@ -285,7 +285,7 @@ def setup_shared_routes(app, index_file='index.html'):
"title": os.path.splitext(filename)[0], "title": os.path.splitext(filename)[0],
"file": f"music_proxy/{rel_path}" "file": f"music_proxy/{rel_path}"
}) })
break # Only top level for now to keep it simple, or remove break for recursive break # Top-level only
return jsonify(library) return jsonify(library)
@app.route('/music_proxy/<path:filename>') @app.route('/music_proxy/<path:filename>')
@ -421,15 +421,33 @@ def setup_shared_routes(app, index_file='index.html'):
@app.route('/stream.mp3') @app.route('/stream.mp3')
def stream_mp3(): def stream_mp3():
# Streaming response from the ffmpeg transcoder output. """Live MP3 audio stream from the ffmpeg transcoder."""
# If ffmpeg isn't available, return 503. # If broadcast is not active, return 503 with audio/mpeg content type.
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: # Returning JSON here would confuse the browser's <audio> element.
return jsonify({"success": False, "error": "MP3 stream not available"}), 503 if not broadcast_state.get('active'):
return Response(b'', status=503, content_type='audio/mpeg', headers={
'Retry-After': '5',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache, no-store',
})
print(f"LISTENER: New listener joined stream (Bursting {_mp3_preroll.maxlen} frames)") # If the transcoder isn't ready yet, wait briefly (it may still be starting)
client_q: queue.Queue = queue.Queue(maxsize=500) waited = 0.0
while (_ffmpeg_proc is None or _ffmpeg_proc.poll() is not None) and waited < 5.0:
eventlet.sleep(0.5)
waited += 0.5
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
return Response(b'', status=503, content_type='audio/mpeg', headers={
'Retry-After': '3',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache, no-store',
})
preroll_count = len(_mp3_preroll)
print(f"LISTENER: New listener joined stream (Pre-roll: {preroll_count} chunks)")
client_q = queue.Queue(maxsize=500)
with _mp3_lock: with _mp3_lock:
# Burst pre-roll to new client so they start playing instantly
for chunk in _mp3_preroll: for chunk in _mp3_preroll:
try: try:
client_q.put_nowait(chunk) client_q.put_nowait(chunk)
@ -438,14 +456,18 @@ def setup_shared_routes(app, index_file='index.html'):
_mp3_clients.add(client_q) _mp3_clients.add(client_q)
def gen(): def gen():
idle_checks = 0
try: try:
while True: while True:
try: try:
chunk = client_q.get(timeout=30) chunk = client_q.get(timeout=5)
idle_checks = 0
except queue.Empty: except queue.Empty:
# No data for 30s - check if broadcast is still active
if not broadcast_state.get('active'): if not broadcast_state.get('active'):
break break
idle_checks += 1
if idle_checks >= 12: # 60s total with no audio data
break
continue continue
if chunk is None: if chunk is None:
break break
@ -453,14 +475,16 @@ def setup_shared_routes(app, index_file='index.html'):
finally: finally:
with _mp3_lock: with _mp3_lock:
_mp3_clients.discard(client_q) _mp3_clients.discard(client_q)
print(f"LISTENER: Listener disconnected from stream") print("LISTENER: Listener disconnected from stream")
return Response(stream_with_context(gen()), content_type='audio/mpeg', headers={ return Response(stream_with_context(gen()), content_type='audio/mpeg', headers={
'Cache-Control': 'no-cache, no-store, must-revalidate', 'Cache-Control': 'no-cache, no-store, must-revalidate',
'Connection': 'keep-alive', 'Connection': 'keep-alive',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Expires': '0', 'Expires': '0',
'X-Content-Type-Options': 'nosniff' 'X-Content-Type-Options': 'nosniff',
'Access-Control-Allow-Origin': '*',
'Icy-Name': 'TechDJ Live',
}) })
@app.route('/stream_debug') @app.route('/stream_debug')
@ -600,17 +624,46 @@ def dj_connect():
print(f"STREAMPANEL: DJ connected: {request.sid}") print(f"STREAMPANEL: DJ connected: {request.sid}")
dj_sids.add(request.sid) dj_sids.add(request.sid)
def _dj_disconnect_grace():
"""Auto-stop broadcast if no DJ reconnects within the grace period."""
global _dj_grace_greenlet
print(f"INFO: Grace period started — {DJ_GRACE_PERIOD_SECS}s for DJ to reconnect")
eventlet.sleep(DJ_GRACE_PERIOD_SECS)
if broadcast_state.get('active') and not dj_sids:
print("WARNING: Grace period expired, no DJ reconnected — auto-stopping broadcast")
broadcast_state['active'] = False
_stop_transcoder()
listener_socketio.emit('broadcast_stopped', namespace='/')
listener_socketio.emit('stream_status', {'active': False}, namespace='/')
_dj_grace_greenlet = None
@dj_socketio.on('disconnect') @dj_socketio.on('disconnect')
def dj_disconnect(): def dj_disconnect():
global _dj_grace_greenlet
dj_sids.discard(request.sid) dj_sids.discard(request.sid)
print("WARNING: DJ disconnected - broadcast will continue until manually stopped") print(f"WARNING: DJ disconnected ({request.sid}). Remaining DJs: {len(dj_sids)}")
def stop_broadcast_after_timeout(): # If broadcast is active and no other DJs remain, start the grace period
"""No longer used - broadcasts don't auto-stop""" if broadcast_state.get('active') and not dj_sids:
if _dj_grace_greenlet is None:
_dj_grace_greenlet = eventlet.spawn(_dj_disconnect_grace)
elif not broadcast_state.get('active'):
# Nothing to do — no active broadcast
pass pass
else:
print("INFO: Other DJ(s) still connected — broadcast continues uninterrupted")
@dj_socketio.on('start_broadcast') @dj_socketio.on('start_broadcast')
def dj_start(data=None): def dj_start(data=None):
global _dj_grace_greenlet
# Cancel any pending auto-stop grace period (DJ reconnected in time)
if _dj_grace_greenlet is not None:
_dj_grace_greenlet.kill()
_dj_grace_greenlet = None
print("INFO: DJ reconnected within grace period — broadcast continues")
was_already_active = broadcast_state.get('active', False) was_already_active = broadcast_state.get('active', False)
broadcast_state['active'] = True broadcast_state['active'] = True
session['is_dj'] = True session['is_dj'] = True

View File

@ -869,8 +869,9 @@ class StreamingWorker(QThread):
chunk = self.ffmpeg_proc.stdout.read(8192) chunk = self.ffmpeg_proc.stdout.read(8192)
if not chunk: if not chunk:
break break
if self.sio.connected: sio = self.sio # Local ref to avoid race with stop_streaming()
self.sio.emit('audio_chunk', chunk) if sio and sio.connected:
sio.emit('audio_chunk', chunk)
except Exception as e: except Exception as e:
self.streaming_error.emit(f"Streaming thread error: {e}") self.streaming_error.emit(f"Streaming thread error: {e}")
@ -1405,11 +1406,9 @@ class DeckWidget(QGroupBox):
def check_queue(self, status): def check_queue(self, status):
if status == QMediaPlayer.MediaStatus.EndOfMedia: if status == QMediaPlayer.MediaStatus.EndOfMedia:
# Check if this is a premature EndOfMedia (common in GStreamer with certain VBR MP3s) # Premature EndOfMedia is common with GStreamer + VBR MP3s
if self.real_duration > 0 and self.player.position() < self.real_duration - 1000: if self.real_duration > 0 and self.player.position() < self.real_duration - 1000:
print(f"[DEBUG] {self.deck_id} Premature EndOfMedia detected. Position: {self.player.position()}, Expected: {self.real_duration}") print(f"[DEBUG] {self.deck_id} Premature EndOfMedia at {self.player.position()}ms (expected {self.real_duration}ms)")
# Don't skip yet, maybe the user wants to seek back?
# Or we could try to play again, but usually GStreamer won't go further.
if self.playback_mode == 1: if self.playback_mode == 1:
# Loop 1 mode # Loop 1 mode

File diff suppressed because one or more lines are too long