Stability fixes: loop end-of-track guard, DJ disconnect grace period, StreamingWorker race condition fix, various audit cleanups
This commit is contained in:
parent
8c3c2613b1
commit
dfccec2b48
|
|
@ -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()
|
||||
BIN
dj_icon.png
BIN
dj_icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 705 KiB |
|
|
@ -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>
|
||||
17
listener.css
17
listener.css
|
|
@ -268,6 +268,7 @@ body.listener-glow::before {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 30px 40px;
|
||||
margin: 30px 0;
|
||||
background: linear-gradient(145deg, #1a1a1a, #0a0a0a);
|
||||
|
|
@ -361,3 +362,19 @@ body.listener-glow::before {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>TechDJ Live</title>
|
||||
<link rel="stylesheet" href="listener.css?v=1.0">
|
||||
<link rel="stylesheet" href="listener.css?v=2.0">
|
||||
</head>
|
||||
|
||||
<body class="listener-glow listening-active">
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
<div class="live-indicator">
|
||||
<span class="pulse-dot"></span>
|
||||
<span>LIVE</span>
|
||||
<span class="listener-count-badge"><span id="listener-count">0</span> listening</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listener-content">
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
|
||||
<!-- Enable Audio Button (shown when autoplay is blocked) -->
|
||||
<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-subtitle">Click to start listening</span>
|
||||
</button>
|
||||
|
|
@ -40,7 +41,7 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
604
listener.js
604
listener.js
|
|
@ -1,9 +1,17 @@
|
|||
// ========== 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';
|
||||
|
||||
// --- State ---
|
||||
// --- Core State ---
|
||||
let socket = null;
|
||||
let listenerAudioContext = null;
|
||||
let listenerGainNode = null;
|
||||
|
|
@ -11,10 +19,21 @@ 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
|
||||
|
||||
// Stall watchdog state
|
||||
let stallWatchdogInterval = null;
|
||||
let lastWatchdogTime = 0;
|
||||
let stallCount = 0;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function getMp3FallbackUrl() {
|
||||
// Use same-origin so this works behind reverse proxies (e.g. Cloudflare)
|
||||
function getMp3StreamUrl() {
|
||||
return `${window.location.origin}/stream.mp3`;
|
||||
}
|
||||
|
||||
|
|
@ -25,6 +44,56 @@ function updateGlowIntensity(val) {
|
|||
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 ---
|
||||
|
||||
function startListenerVUMeter() {
|
||||
|
|
@ -41,7 +110,7 @@ function startListenerVUMeter() {
|
|||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Keep canvas sized correctly for DPI
|
||||
// DPI-aware canvas sizing
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const targetW = Math.max(1, Math.floor(rect.width * dpr));
|
||||
|
|
@ -51,10 +120,9 @@ function startListenerVUMeter() {
|
|||
canvas.height = targetH;
|
||||
}
|
||||
|
||||
const analyser = listenerAnalyserNode;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const bufferLength = listenerAnalyserNode.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
listenerAnalyserNode.getByteFrequencyData(dataArray);
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
|
@ -64,8 +132,7 @@ function startListenerVUMeter() {
|
|||
ctx.fillStyle = '#0a0a12';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Magenta hue (matches Deck B styling)
|
||||
const hue = 280;
|
||||
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;
|
||||
|
|
@ -84,13 +151,101 @@ function startListenerVUMeter() {
|
|||
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 ---
|
||||
|
||||
function initSocket() {
|
||||
if (socket) return socket;
|
||||
|
||||
const serverUrl = window.location.origin;
|
||||
console.log(`[LISTENER] Initializing Socket.IO connection to: ${serverUrl}`);
|
||||
console.log(`[SOCKET] Connecting to ${serverUrl}`);
|
||||
|
||||
socket = io(serverUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
|
|
@ -101,364 +256,275 @@ function initSocket() {
|
|||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[OK] Connected to streaming server');
|
||||
console.log('[SOCKET] Connected');
|
||||
socket.emit('join_listener');
|
||||
socket.emit('get_listener_count');
|
||||
if (window.listenerAudioEnabled) {
|
||||
updateStatus('Connected', true);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[ERROR] Connection error:', error.message);
|
||||
console.error('[SOCKET] Connection error:', error.message);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const el = document.getElementById('listener-count');
|
||||
if (el) el.textContent = data.count;
|
||||
updateListenerCount(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;
|
||||
}
|
||||
|
||||
/** Clean up when broadcast goes offline. */
|
||||
function handleBroadcastOffline() {
|
||||
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('[LISTENER] Initializing listener mode (MP3 stream)...');
|
||||
|
||||
// Apply glow
|
||||
console.log('[INIT] TechDJ Listener starting...');
|
||||
updateGlowIntensity(30);
|
||||
|
||||
// Clean up old audio element if it exists (e.g. page refresh)
|
||||
// Clean up any leftover audio element (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.load();
|
||||
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;
|
||||
}
|
||||
|
||||
} catch (_) { }
|
||||
cleanupAudioGraph();
|
||||
window.listenerAudio = null;
|
||||
window.listenerMediaSource = null;
|
||||
window.listenerAudioEnabled = false;
|
||||
}
|
||||
|
||||
// Create fresh audio element
|
||||
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.style.display = 'none';
|
||||
audio.crossOrigin = 'anonymous';
|
||||
audio.preload = 'none';
|
||||
audio.style.display = 'none';
|
||||
document.body.appendChild(audio);
|
||||
console.log('[NEW] Created fresh audio element for listener');
|
||||
|
||||
// --- Stall Watchdog ---
|
||||
let lastCheckedTime = 0;
|
||||
let stallCount = 0;
|
||||
let watchdogInterval = null;
|
||||
// --- Audio element event handlers ---
|
||||
|
||||
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();
|
||||
const err = audio.error;
|
||||
console.error(`[AUDIO] Error: code=${err?.code}, msg=${err?.message}`);
|
||||
|
||||
if (broadcastActive) {
|
||||
scheduleReconnect();
|
||||
} else {
|
||||
updateStatus('Stream offline', false);
|
||||
}
|
||||
};
|
||||
|
||||
audio.onplay = () => {
|
||||
console.log('[PLAY] Stream playing');
|
||||
startWatchdog();
|
||||
const statusEl = document.getElementById('connection-status');
|
||||
if (statusEl) statusEl.classList.add('glow-text');
|
||||
startStallWatchdog();
|
||||
if (window.listenerAudioEnabled && broadcastActive) {
|
||||
updateStatus('Audio Active — Enjoy the stream!', true);
|
||||
}
|
||||
};
|
||||
|
||||
audio.onpause = () => {
|
||||
console.log('[PAUSE] Stream paused');
|
||||
stopWatchdog();
|
||||
stopStallWatchdog();
|
||||
};
|
||||
|
||||
// Show enable audio button
|
||||
const enableAudioBtn = document.getElementById('enable-audio-btn');
|
||||
const statusEl = document.getElementById('connection-status');
|
||||
audio.onwaiting = () => {
|
||||
if (window.listenerAudioEnabled) {
|
||||
updateStatus('Buffering...', false);
|
||||
}
|
||||
};
|
||||
|
||||
if (enableAudioBtn) {
|
||||
enableAudioBtn.style.display = 'flex';
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.textContent = '[INFO] Click "Enable Audio" to start listening (MP3)';
|
||||
}
|
||||
audio.onplaying = () => {
|
||||
if (window.listenerAudioEnabled && broadcastActive) {
|
||||
updateStatus('Audio Active — Enjoy the stream!', true);
|
||||
}
|
||||
};
|
||||
|
||||
// Store for later activation
|
||||
// Store globally
|
||||
window.listenerAudio = audio;
|
||||
window.listenerMediaSource = null;
|
||||
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();
|
||||
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() {
|
||||
console.log('[LISTENER] Enabling audio via user gesture...');
|
||||
console.log('[ENABLE] User clicked Enable Audio');
|
||||
|
||||
const enableAudioBtn = document.getElementById('enable-audio-btn');
|
||||
const statusEl = document.getElementById('connection-status');
|
||||
const audioText = enableAudioBtn ? enableAudioBtn.querySelector('.audio-text') : null;
|
||||
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 AudioContext if needed
|
||||
// 1. Create/resume AudioContext (must happen inside a user gesture)
|
||||
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');
|
||||
}
|
||||
console.log('[OK] AudioContext active');
|
||||
|
||||
// 3. Bridge Audio Element to AudioContext
|
||||
// 2. Build audio graph: source → analyser → gain → speakers
|
||||
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);
|
||||
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 { listenerAnalyserNode.disconnect(); } catch (_) { }
|
||||
|
||||
listenerMediaElementSourceNode.connect(listenerAnalyserNode);
|
||||
listenerAnalyserNode.connect(listenerGainNode);
|
||||
|
||||
window.listenerAudio._connectedToContext = true;
|
||||
console.log('[OK] Connected audio element to AudioContext (with analyser)');
|
||||
|
||||
startListenerVUMeter();
|
||||
console.log('[OK] Audio graph connected');
|
||||
} 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
|
||||
if (window.listenerAudio) {
|
||||
window.listenerAudio.muted = false;
|
||||
window.listenerAudio.volume = 1.0;
|
||||
// 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);
|
||||
|
||||
const volEl = document.getElementById('listener-volume');
|
||||
const volValue = volEl ? parseInt(volEl.value, 10) : 80;
|
||||
setListenerVolume(Number.isFinite(volValue) ? volValue : 80);
|
||||
// 4. Mark enabled
|
||||
window.listenerAudioEnabled = true;
|
||||
|
||||
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 button with a smooth transition
|
||||
if (btn) {
|
||||
btn.style.opacity = '0';
|
||||
btn.style.pointerEvents = 'none';
|
||||
setTimeout(() => { btn.style.display = 'none'; }, 300);
|
||||
}
|
||||
|
||||
// 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');
|
||||
// 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] 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;
|
||||
console.error('[ERROR] Enable audio failed:', error);
|
||||
window.listenerAudioEnabled = false;
|
||||
|
||||
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 (audioText) audioText.textContent = 'TAP TO RETRY';
|
||||
if (audioSubtitle) audioSubtitle.textContent = '';
|
||||
if (btn) btn.style.pointerEvents = 'auto';
|
||||
|
||||
if (error.name === 'NotSupportedError') {
|
||||
stashedStatus.textContent = 'MP3 stream failed. Is ffmpeg installed on the server?';
|
||||
}
|
||||
if (error.name === 'NotAllowedError') {
|
||||
updateStatus('Browser blocked audio — tap the button again.', false);
|
||||
} else {
|
||||
updateStatus(`Error: ${error.message}`, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -467,7 +533,7 @@ async function enableListenerAudio() {
|
|||
|
||||
function setListenerVolume(value) {
|
||||
if (listenerGainNode) {
|
||||
listenerGainNode.gain.value = value / 100;
|
||||
listenerGainNode.gain.value = Math.max(0, Math.min(1, value / 100));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
99
script.js
99
script.js
|
|
@ -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
|
||||
function vibrate(ms) {
|
||||
if (navigator.vibrate) {
|
||||
|
|
@ -608,9 +601,13 @@ function drawWaveform(id) {
|
|||
const data = decks[id].waveformData;
|
||||
if (!data) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
// Use logical (CSS) dimensions — the canvas context has ctx.scale(dpr)
|
||||
// applied in initSystem, so coordinates must be in logical pixels.
|
||||
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;
|
||||
|
||||
data.forEach((val, i) => {
|
||||
|
|
@ -804,8 +801,6 @@ function playDeck(id) {
|
|||
if (deckEl) deckEl.classList.add('playing');
|
||||
document.body.classList.add('playing-' + id);
|
||||
|
||||
|
||||
|
||||
if (audioCtx.state === 'suspended') {
|
||||
console.log(`[Deck ${id}] Resuming suspended AudioContext`);
|
||||
audioCtx.resume();
|
||||
|
|
@ -824,8 +819,6 @@ function playDeck(id) {
|
|||
const deckEl = document.getElementById('deck-' + id);
|
||||
if (deckEl) deckEl.classList.remove('playing');
|
||||
document.body.classList.remove('playing-' + id);
|
||||
|
||||
|
||||
alert(`Playback error: ${error.message}`);
|
||||
}
|
||||
} 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() {
|
||||
const query = document.getElementById('lib-search').value.toLowerCase();
|
||||
const filtered = allSongs.filter(s => s.title.toLowerCase().includes(query));
|
||||
|
|
@ -1411,7 +1397,6 @@ function refreshLibrary() {
|
|||
fetchLibrary();
|
||||
}
|
||||
|
||||
|
||||
async function loadFromServer(id, url, title) {
|
||||
const d = document.getElementById('display-' + id);
|
||||
d.innerText = '[WAIT] LOADING...';
|
||||
|
|
@ -1839,9 +1824,7 @@ function initSocket() {
|
|||
if (socket) return socket;
|
||||
|
||||
const serverUrl = window.location.origin;
|
||||
console.log(`CONNECT Initializing Socket.IO connection to: ${serverUrl}`);
|
||||
console.log(` Protocol: ${window.location.protocol}`);
|
||||
console.log(` Host: ${window.location.host}`);
|
||||
console.log(`[SOCKET] Connecting to ${serverUrl}`);
|
||||
|
||||
socket = io(serverUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
|
|
@ -1853,21 +1836,15 @@ function initSocket() {
|
|||
|
||||
socket.on('connect', () => {
|
||||
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.on('connect_error', (error) => {
|
||||
console.error('[ERROR] Connection error:', error.message);
|
||||
console.error(' Make sure server is running on', serverUrl);
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('[ERROR] Disconnected from streaming server');
|
||||
console.log(` Reason: ${reason}`);
|
||||
console.warn(`[WARN] Disconnected: ${reason}`);
|
||||
});
|
||||
|
||||
socket.on('listener_count', (data) => {
|
||||
|
|
@ -1876,7 +1853,7 @@ function initSocket() {
|
|||
});
|
||||
|
||||
socket.on('broadcast_started', () => {
|
||||
console.log('Broadcast started notification received');
|
||||
console.log('[EVENT] broadcast_started');
|
||||
// Update relay UI if it's a relay
|
||||
const relayStatus = document.getElementById('relay-status');
|
||||
if (relayStatus && relayStatus.textContent.includes('Connecting')) {
|
||||
|
|
@ -1886,7 +1863,7 @@ function initSocket() {
|
|||
});
|
||||
|
||||
socket.on('broadcast_stopped', () => {
|
||||
console.log('STOP Broadcast stopped notification received');
|
||||
console.log('[EVENT] broadcast_stopped');
|
||||
// Reset relay UI if it was active
|
||||
const startRelayBtn = document.getElementById('start-relay-btn');
|
||||
const stopRelayBtn = document.getElementById('stop-relay-btn');
|
||||
|
|
@ -2025,7 +2002,7 @@ function startBroadcast() {
|
|||
isBroadcasting = true;
|
||||
document.getElementById('broadcast-btn').classList.add('active');
|
||||
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');
|
||||
|
||||
if (!socket) initSocket();
|
||||
|
|
@ -2228,7 +2205,7 @@ function startBroadcast() {
|
|||
isBroadcasting = true;
|
||||
document.getElementById('broadcast-btn').classList.add('active');
|
||||
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');
|
||||
|
||||
// Notify server that broadcast is active (listeners use MP3 stream)
|
||||
|
|
@ -2259,7 +2236,7 @@ function startBroadcast() {
|
|||
|
||||
// Stop broadcasting
|
||||
function stopBroadcast() {
|
||||
console.log('STOP Stopping broadcast...');
|
||||
console.log('[BROADCAST] Stopping...');
|
||||
|
||||
if (SERVER_SIDE_AUDIO) {
|
||||
isBroadcasting = false;
|
||||
|
|
@ -2313,7 +2290,6 @@ function stopBroadcast() {
|
|||
console.log('[OK] Broadcast stopped');
|
||||
}
|
||||
|
||||
|
||||
// Restart broadcasting (for auto-recovery)
|
||||
function restartBroadcast() {
|
||||
console.log('Restarting broadcast...');
|
||||
|
|
@ -2367,19 +2343,31 @@ function restartBroadcast() {
|
|||
// Copy stream URL to clipboard
|
||||
function copyStreamUrl(evt) {
|
||||
const urlInput = document.getElementById('stream-url');
|
||||
urlInput.select();
|
||||
urlInput.setSelectionRange(0, 99999); // For mobile
|
||||
const text = urlInput.value;
|
||||
const btn = evt?.target;
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
const btn = evt?.target;
|
||||
const showFeedback = (success) => {
|
||||
if (!btn) return;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'OK';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
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 {
|
||||
document.execCommand('copy');
|
||||
showFeedback(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
showFeedback(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2416,7 +2404,10 @@ function monitorTrackEnd() {
|
|||
const remaining = decks[id].duration - current;
|
||||
|
||||
// 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
|
||||
if (isBroadcasting) {
|
||||
console.log(`Track ending during broadcast on Deck ${id}`);
|
||||
|
|
@ -2482,6 +2473,7 @@ function monitorTrackEnd() {
|
|||
}, 500); // Check every 0.5s
|
||||
}
|
||||
monitorTrackEnd();
|
||||
|
||||
// Reset Deck to Default Settings
|
||||
function resetDeck(id) {
|
||||
vibrate(20);
|
||||
|
|
@ -2555,7 +2547,6 @@ function resetDeck(id) {
|
|||
|
||||
console.log(`[OK] Deck ${id} reset complete!`);
|
||||
|
||||
|
||||
// Visual feedback
|
||||
const resetBtn = document.querySelector(`#deck-${id} .reset-btn`);
|
||||
if (resetBtn) {
|
||||
|
|
@ -2966,8 +2957,12 @@ function renderKeyboardMappings() {
|
|||
<span class="key-display">${formatKeyName(key)}</span>
|
||||
<span class="key-arrow">RIGHT</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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
99
server.py
99
server.py
|
|
@ -13,7 +13,6 @@ import collections
|
|||
from flask import Flask, send_from_directory, jsonify, request, session, Response, stream_with_context, abort
|
||||
from flask_socketio import SocketIO, emit
|
||||
from dotenv import load_dotenv
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
|
|
@ -47,23 +46,27 @@ CONFIG_DEBUG = bool(CONFIG.get('debug', False))
|
|||
DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip()
|
||||
DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD)
|
||||
|
||||
# Relay State
|
||||
# Broadcast State
|
||||
broadcast_state = {
|
||||
'active': False,
|
||||
}
|
||||
listener_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) ===
|
||||
_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()
|
||||
_mp3_clients = set() # set[queue.Queue]
|
||||
_mp3_lock = threading.Lock()
|
||||
_transcoder_bytes_out = 0
|
||||
_transcoder_last_error = None
|
||||
_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):
|
||||
|
|
@ -254,7 +257,6 @@ def _load_settings():
|
|||
return {}
|
||||
|
||||
SETTINGS = _load_settings()
|
||||
# Config.json music_folder overrides settings.json if set
|
||||
_config_music = (CONFIG.get('music_folder') or '').strip()
|
||||
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 = []
|
||||
global 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 filename in sorted(files):
|
||||
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],
|
||||
"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)
|
||||
|
||||
@app.route('/music_proxy/<path:filename>')
|
||||
|
|
@ -421,15 +421,33 @@ def setup_shared_routes(app, index_file='index.html'):
|
|||
|
||||
@app.route('/stream.mp3')
|
||||
def stream_mp3():
|
||||
# Streaming response from the ffmpeg transcoder output.
|
||||
# If ffmpeg isn't available, return 503.
|
||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||
return jsonify({"success": False, "error": "MP3 stream not available"}), 503
|
||||
"""Live MP3 audio stream from the ffmpeg transcoder."""
|
||||
# If broadcast is not active, return 503 with audio/mpeg content type.
|
||||
# Returning JSON here would confuse the browser's <audio> element.
|
||||
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)")
|
||||
client_q: queue.Queue = queue.Queue(maxsize=500)
|
||||
# If the transcoder isn't ready yet, wait briefly (it may still be starting)
|
||||
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:
|
||||
# Burst pre-roll to new client so they start playing instantly
|
||||
for chunk in _mp3_preroll:
|
||||
try:
|
||||
client_q.put_nowait(chunk)
|
||||
|
|
@ -438,14 +456,18 @@ def setup_shared_routes(app, index_file='index.html'):
|
|||
_mp3_clients.add(client_q)
|
||||
|
||||
def gen():
|
||||
idle_checks = 0
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
chunk = client_q.get(timeout=30)
|
||||
chunk = client_q.get(timeout=5)
|
||||
idle_checks = 0
|
||||
except queue.Empty:
|
||||
# No data for 30s - check if broadcast is still active
|
||||
if not broadcast_state.get('active'):
|
||||
break
|
||||
idle_checks += 1
|
||||
if idle_checks >= 12: # 60s total with no audio data
|
||||
break
|
||||
continue
|
||||
if chunk is None:
|
||||
break
|
||||
|
|
@ -453,14 +475,16 @@ def setup_shared_routes(app, index_file='index.html'):
|
|||
finally:
|
||||
with _mp3_lock:
|
||||
_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={
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Connection': 'keep-alive',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Icy-Name': 'TechDJ Live',
|
||||
})
|
||||
|
||||
@app.route('/stream_debug')
|
||||
|
|
@ -600,17 +624,46 @@ def dj_connect():
|
|||
print(f"STREAMPANEL: DJ connected: {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')
|
||||
def dj_disconnect():
|
||||
global _dj_grace_greenlet
|
||||
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():
|
||||
"""No longer used - broadcasts don't auto-stop"""
|
||||
pass
|
||||
# If broadcast is active and no other DJs remain, start the grace period
|
||||
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
|
||||
else:
|
||||
print("INFO: Other DJ(s) still connected — broadcast continues uninterrupted")
|
||||
|
||||
@dj_socketio.on('start_broadcast')
|
||||
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)
|
||||
broadcast_state['active'] = True
|
||||
session['is_dj'] = True
|
||||
|
|
|
|||
11
techdj_qt.py
11
techdj_qt.py
|
|
@ -869,8 +869,9 @@ class StreamingWorker(QThread):
|
|||
chunk = self.ffmpeg_proc.stdout.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
if self.sio.connected:
|
||||
self.sio.emit('audio_chunk', chunk)
|
||||
sio = self.sio # Local ref to avoid race with stop_streaming()
|
||||
if sio and sio.connected:
|
||||
sio.emit('audio_chunk', chunk)
|
||||
|
||||
except Exception as e:
|
||||
self.streaming_error.emit(f"Streaming thread error: {e}")
|
||||
|
|
@ -1405,11 +1406,9 @@ class DeckWidget(QGroupBox):
|
|||
|
||||
def check_queue(self, status):
|
||||
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:
|
||||
print(f"[DEBUG] {self.deck_id} Premature EndOfMedia detected. Position: {self.player.position()}, Expected: {self.real_duration}")
|
||||
# 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.
|
||||
print(f"[DEBUG] {self.deck_id} Premature EndOfMedia at {self.player.position()}ms (expected {self.real_duration}ms)")
|
||||
|
||||
if self.playback_mode == 1:
|
||||
# Loop 1 mode
|
||||
|
|
|
|||
2801
techdj_qt_v1.py.bak
2801
techdj_qt_v1.py.bak
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue