Separate listener page from DJ panel
- Created standalone listener.html, listener.js, listener.css - Listener server now serves listener.html instead of index.html - Eliminates flash of DJ panel when loading listener page - setup_shared_routes() accepts index_file parameter
This commit is contained in:
parent
df283498eb
commit
5a7f4e81a4
|
|
@ -0,0 +1,363 @@
|
||||||
|
/* ========== TechDJ Listener Stylesheet ========== */
|
||||||
|
/* Standalone styles — no DJ panel CSS loaded. */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Rajdhani:wght@300;500;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-dark: #0a0a12;
|
||||||
|
--panel-bg: rgba(20, 20, 30, 0.8);
|
||||||
|
--primary-cyan: #00f3ff;
|
||||||
|
--secondary-magenta: #bc13fe;
|
||||||
|
--text-main: #e0e0e0;
|
||||||
|
--text-dim: #888;
|
||||||
|
--glass-border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
--glow-opacity: 0.3;
|
||||||
|
--glow-spread: 30px;
|
||||||
|
--glow-border: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 10% 20%, rgba(0, 243, 255, 0.15) 0%, transparent 25%),
|
||||||
|
radial-gradient(circle at 90% 80%, rgba(188, 19, 254, 0.15) 0%, transparent 25%),
|
||||||
|
radial-gradient(circle at 50% 50%, rgba(0, 243, 255, 0.05) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: var(--glow-opacity, 0.3);
|
||||||
|
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Listener atmospheric glow */
|
||||||
|
body.listener-glow::before {
|
||||||
|
animation: pulse-listener 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-listener {
|
||||||
|
0%, 100% {
|
||||||
|
filter: hue-rotate(0deg) brightness(1.2);
|
||||||
|
box-shadow:
|
||||||
|
0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1.5)),
|
||||||
|
0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1.5)),
|
||||||
|
inset 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1)),
|
||||||
|
inset 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: hue-rotate(15deg) brightness(1.8);
|
||||||
|
box-shadow:
|
||||||
|
0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 2.2)),
|
||||||
|
0 0 calc(var(--glow-spread) * 2) rgba(220, 50, 255, calc(var(--glow-opacity) * 2.2)),
|
||||||
|
0 0 calc(var(--glow-spread) * 4) rgba(0, 243, 255, calc(var(--glow-opacity) * 1)),
|
||||||
|
inset 0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 1.5)),
|
||||||
|
inset 0 0 calc(var(--glow-spread) * 2) rgba(220, 50, 255, calc(var(--glow-opacity) * 1.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Listener Mode Layout ========== */
|
||||||
|
|
||||||
|
.listener-mode {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, #0a0a12 0%, #1a0a1a 100%);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listener-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listener-header h1 {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--secondary-magenta);
|
||||||
|
text-shadow: 0 0 30px var(--secondary-magenta);
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Glow Text ========== */
|
||||||
|
|
||||||
|
.glow-text {
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 10px var(--secondary-magenta), 0 0 20px var(--secondary-magenta);
|
||||||
|
animation: text-glow-pulse 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes text-glow-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.8;
|
||||||
|
text-shadow: 0 0 10px var(--secondary-magenta);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
text-shadow: 0 0 15px var(--secondary-magenta), 0 0 30px var(--secondary-magenta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Live Indicator ========== */
|
||||||
|
|
||||||
|
.live-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: rgba(188, 19, 254, 0.2);
|
||||||
|
border: 2px solid var(--secondary-magenta);
|
||||||
|
border-radius: 25px;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--secondary-magenta);
|
||||||
|
box-shadow: 0 0 20px rgba(188, 19, 254, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--secondary-magenta);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.3);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Listener Content Card ========== */
|
||||||
|
|
||||||
|
.listener-content {
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(10, 10, 20, 0.8);
|
||||||
|
border: 2px solid var(--secondary-magenta);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 0 40px rgba(188, 19, 254, 0.3);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Visualiser ========== */
|
||||||
|
|
||||||
|
#viz-listener {
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
display: block;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Now Playing ========== */
|
||||||
|
|
||||||
|
.now-playing {
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Volume ========== */
|
||||||
|
|
||||||
|
.volume-control {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(188, 19, 254, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--secondary-magenta);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 10px var(--secondary-magenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--secondary-magenta);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 0 10px var(--secondary-magenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Connection Status ========== */
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.connected {
|
||||||
|
color: #00ff00;
|
||||||
|
text-shadow: 0 0 10px #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.disconnected {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Enable Audio Button ========== */
|
||||||
|
|
||||||
|
.enable-audio-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 30px 40px;
|
||||||
|
margin: 30px 0;
|
||||||
|
background: linear-gradient(145deg, #1a1a1a, #0a0a0a);
|
||||||
|
border: 3px solid var(--secondary-magenta);
|
||||||
|
border-radius: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 0 30px rgba(188, 19, 254, 0.3);
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-audio-btn:hover {
|
||||||
|
background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
|
||||||
|
box-shadow: 0 0 50px rgba(188, 19, 254, 0.6);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: #ff00ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-audio-btn:active {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 40px rgba(188, 19, 254, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-audio-btn .audio-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
animation: pulse-icon 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-icon {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-audio-btn .audio-text {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--secondary-magenta);
|
||||||
|
text-shadow: 0 0 10px var(--secondary-magenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-audio-btn .audio-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Mobile Responsiveness ========== */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.listener-mode {
|
||||||
|
padding: 20px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listener-header h1 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-indicator {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 6px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listener-content {
|
||||||
|
padding: 25px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
min-height: 80px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#viz-listener {
|
||||||
|
height: 60px !important;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="listener-glow listening-active">
|
||||||
|
<!-- Listener UI -->
|
||||||
|
<div class="listener-mode" id="listener-mode">
|
||||||
|
<div class="listener-header">
|
||||||
|
<h1>TECHDJ LIVE</h1>
|
||||||
|
<div class="live-indicator">
|
||||||
|
<span class="pulse-dot"></span>
|
||||||
|
<span>LIVE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="listener-content">
|
||||||
|
<div class="now-playing" id="listener-now-playing">Waiting for stream...</div>
|
||||||
|
|
||||||
|
<canvas id="viz-listener" width="400" height="100"></canvas>
|
||||||
|
|
||||||
|
<!-- 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-text">ENABLE AUDIO</span>
|
||||||
|
<span class="audio-subtitle">Click to start listening</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="volume-control">
|
||||||
|
<label>Volume</label>
|
||||||
|
<input type="range" id="listener-volume" min="0" max="100" value="80"
|
||||||
|
oninput="setListenerVolume(this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="connection-status" id="connection-status">Connecting...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||||
|
<script src="listener.js?v=1.0"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
// ========== TechDJ Listener ==========
|
||||||
|
// Standalone listener script — no DJ panel code loaded.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let socket = null;
|
||||||
|
let listenerAudioContext = null;
|
||||||
|
let listenerGainNode = null;
|
||||||
|
let listenerAnalyserNode = null;
|
||||||
|
let listenerMediaElementSourceNode = null;
|
||||||
|
let listenerVuMeterRunning = false;
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function getMp3FallbackUrl() {
|
||||||
|
// Use same-origin so this works behind reverse proxies (e.g. Cloudflare)
|
||||||
|
return `${window.location.origin}/stream.mp3`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGlowIntensity(val) {
|
||||||
|
const opacity = parseInt(val, 10) / 100;
|
||||||
|
const spread = (parseInt(val, 10) / 100) * 80;
|
||||||
|
document.documentElement.style.setProperty('--glow-opacity', opacity);
|
||||||
|
document.documentElement.style.setProperty('--glow-spread', `${spread}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- VU Meter ---
|
||||||
|
|
||||||
|
function startListenerVUMeter() {
|
||||||
|
if (listenerVuMeterRunning) return;
|
||||||
|
listenerVuMeterRunning = true;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
if (!listenerVuMeterRunning) return;
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
const canvas = document.getElementById('viz-listener');
|
||||||
|
if (!canvas || !listenerAnalyserNode) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Keep canvas sized correctly for DPI
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const targetW = Math.max(1, Math.floor(rect.width * dpr));
|
||||||
|
const targetH = Math.max(1, Math.floor(rect.height * dpr));
|
||||||
|
if (canvas.width !== targetW || canvas.height !== targetH) {
|
||||||
|
canvas.width = targetW;
|
||||||
|
canvas.height = targetH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyser = listenerAnalyserNode;
|
||||||
|
const bufferLength = analyser.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
const width = canvas.width;
|
||||||
|
const height = canvas.height;
|
||||||
|
const barCount = 32;
|
||||||
|
const barWidth = width / barCount;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0a0a12';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Magenta hue (matches Deck B styling)
|
||||||
|
const hue = 280;
|
||||||
|
for (let i = 0; i < barCount; i++) {
|
||||||
|
const freqIndex = Math.floor(Math.pow(i / barCount, 1.5) * bufferLength);
|
||||||
|
const value = (dataArray[freqIndex] || 0) / 255;
|
||||||
|
const barHeight = value * height;
|
||||||
|
|
||||||
|
const lightness = 30 + (value * 50);
|
||||||
|
const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight);
|
||||||
|
gradient.addColorStop(0, `hsl(${hue}, 100%, ${lightness}%)`);
|
||||||
|
gradient.addColorStop(1, `hsl(${hue}, 100%, ${Math.min(lightness + 20, 80)}%)`);
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(i * barWidth, height - barHeight, barWidth - 2, barHeight);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Socket.IO ---
|
||||||
|
|
||||||
|
function initSocket() {
|
||||||
|
if (socket) return socket;
|
||||||
|
|
||||||
|
const serverUrl = window.location.origin;
|
||||||
|
console.log(`[LISTENER] Initializing Socket.IO connection to: ${serverUrl}`);
|
||||||
|
|
||||||
|
socket = io(serverUrl, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('[OK] Connected to streaming server');
|
||||||
|
socket.emit('get_listener_count');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.error('[ERROR] Connection error:', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason) => {
|
||||||
|
console.log(`[ERROR] Disconnected: ${reason}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('listener_count', (data) => {
|
||||||
|
const el = document.getElementById('listener-count');
|
||||||
|
if (el) el.textContent = data.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Listener Mode Init ---
|
||||||
|
|
||||||
|
function initListenerMode() {
|
||||||
|
console.log('[LISTENER] Initializing listener mode (MP3 stream)...');
|
||||||
|
|
||||||
|
// Apply glow
|
||||||
|
updateGlowIntensity(30);
|
||||||
|
|
||||||
|
// Clean up old audio element if it exists (e.g. page refresh)
|
||||||
|
if (window.listenerAudio) {
|
||||||
|
console.log('[CLEAN] Cleaning up old audio element');
|
||||||
|
try {
|
||||||
|
window.listenerAudio.pause();
|
||||||
|
if (window.listenerAudio.src) {
|
||||||
|
URL.revokeObjectURL(window.listenerAudio.src);
|
||||||
|
}
|
||||||
|
window.listenerAudio.removeAttribute('src');
|
||||||
|
window.listenerAudio.remove();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error cleaning up old audio:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listenerMediaElementSourceNode) {
|
||||||
|
try { listenerMediaElementSourceNode.disconnect(); } catch (_) { }
|
||||||
|
listenerMediaElementSourceNode = null;
|
||||||
|
}
|
||||||
|
if (listenerAnalyserNode) {
|
||||||
|
try { listenerAnalyserNode.disconnect(); } catch (_) { }
|
||||||
|
listenerAnalyserNode = null;
|
||||||
|
}
|
||||||
|
if (listenerGainNode) {
|
||||||
|
try { listenerGainNode.disconnect(); } catch (_) { }
|
||||||
|
listenerGainNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.listenerAudio = null;
|
||||||
|
window.listenerMediaSource = null;
|
||||||
|
window.listenerAudioEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fresh audio element
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
audio.autoplay = false;
|
||||||
|
audio.muted = false;
|
||||||
|
audio.controls = false;
|
||||||
|
audio.playsInline = true;
|
||||||
|
audio.setAttribute('playsinline', '');
|
||||||
|
audio.style.display = 'none';
|
||||||
|
audio.crossOrigin = 'anonymous';
|
||||||
|
document.body.appendChild(audio);
|
||||||
|
console.log('[NEW] Created fresh audio element for listener');
|
||||||
|
|
||||||
|
// --- Stall Watchdog ---
|
||||||
|
let lastCheckedTime = 0;
|
||||||
|
let stallCount = 0;
|
||||||
|
let watchdogInterval = null;
|
||||||
|
|
||||||
|
const stopWatchdog = () => {
|
||||||
|
if (watchdogInterval) {
|
||||||
|
clearInterval(watchdogInterval);
|
||||||
|
watchdogInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startWatchdog = () => {
|
||||||
|
stopWatchdog();
|
||||||
|
lastCheckedTime = audio.currentTime;
|
||||||
|
stallCount = 0;
|
||||||
|
|
||||||
|
watchdogInterval = setInterval(() => {
|
||||||
|
if (!window.listenerAudioEnabled || audio.paused) return;
|
||||||
|
|
||||||
|
if (audio.currentTime === lastCheckedTime && audio.currentTime > 0) {
|
||||||
|
stallCount++;
|
||||||
|
console.warn(`[WARN] Stream stall detected (${stallCount}/3)...`);
|
||||||
|
|
||||||
|
if (stallCount >= 3) {
|
||||||
|
console.error('[ALERT] Stream stalled. Force reconnecting...');
|
||||||
|
reconnectStream();
|
||||||
|
stallCount = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stallCount = 0;
|
||||||
|
}
|
||||||
|
lastCheckedTime = audio.currentTime;
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconnectStream = () => {
|
||||||
|
if (!window.listenerAudioEnabled || !window.listenerAudio) return;
|
||||||
|
|
||||||
|
console.log('[RECONNECT] Reconnecting stream...');
|
||||||
|
const statusEl = document.getElementById('connection-status');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '[WAIT] Connection weak - Reconnecting...';
|
||||||
|
statusEl.classList.remove('glow-text');
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasPaused = window.listenerAudio.paused;
|
||||||
|
window.listenerAudio.src = getMp3FallbackUrl() + '?t=' + Date.now();
|
||||||
|
window.listenerAudio.load();
|
||||||
|
|
||||||
|
if (!wasPaused) {
|
||||||
|
window.listenerAudio.play()
|
||||||
|
.then(() => {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '[ACTIVE] Reconnected';
|
||||||
|
statusEl.classList.add('glow-text');
|
||||||
|
}
|
||||||
|
startListenerVUMeter();
|
||||||
|
})
|
||||||
|
.catch(e => console.warn('Reconnect play failed:', e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set MP3 stream source
|
||||||
|
audio.src = getMp3FallbackUrl();
|
||||||
|
audio.load();
|
||||||
|
console.log(`[STREAM] Listener source set to MP3 stream: ${audio.src}`);
|
||||||
|
|
||||||
|
// Auto-reconnect on stream error
|
||||||
|
audio.onerror = () => {
|
||||||
|
if (!window.listenerAudioEnabled) return;
|
||||||
|
console.error('[ERROR] Audio stream error!');
|
||||||
|
reconnectStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.onplay = () => {
|
||||||
|
console.log('[PLAY] Stream playing');
|
||||||
|
startWatchdog();
|
||||||
|
const statusEl = document.getElementById('connection-status');
|
||||||
|
if (statusEl) statusEl.classList.add('glow-text');
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.onpause = () => {
|
||||||
|
console.log('[PAUSE] Stream paused');
|
||||||
|
stopWatchdog();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show enable audio button
|
||||||
|
const enableAudioBtn = document.getElementById('enable-audio-btn');
|
||||||
|
const statusEl = document.getElementById('connection-status');
|
||||||
|
|
||||||
|
if (enableAudioBtn) {
|
||||||
|
enableAudioBtn.style.display = 'flex';
|
||||||
|
}
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '[INFO] Click "Enable Audio" to start listening (MP3)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store for later activation
|
||||||
|
window.listenerAudio = audio;
|
||||||
|
window.listenerMediaSource = null;
|
||||||
|
window.listenerAudioEnabled = false;
|
||||||
|
|
||||||
|
// Initialise socket and join
|
||||||
|
initSocket();
|
||||||
|
socket.emit('join_listener');
|
||||||
|
|
||||||
|
// --- Socket event handlers ---
|
||||||
|
|
||||||
|
socket.on('broadcast_started', () => {
|
||||||
|
const nowPlayingEl = document.getElementById('listener-now-playing');
|
||||||
|
if (nowPlayingEl) nowPlayingEl.textContent = 'Stream is live!';
|
||||||
|
|
||||||
|
if (window.listenerAudio) {
|
||||||
|
console.log('[BROADCAST] Broadcast started: Refreshing audio stream...');
|
||||||
|
const wasPlaying = !window.listenerAudio.paused;
|
||||||
|
window.listenerAudio.src = getMp3FallbackUrl();
|
||||||
|
window.listenerAudio.load();
|
||||||
|
if (wasPlaying || window.listenerAudioEnabled) {
|
||||||
|
window.listenerAudio.play().catch(e => console.warn('Auto-play after refresh blocked:', e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('stream_status', (data) => {
|
||||||
|
const nowPlayingEl = document.getElementById('listener-now-playing');
|
||||||
|
if (nowPlayingEl) {
|
||||||
|
if (data.active) {
|
||||||
|
const status = data.remote_relay ? 'Remote stream is live!' : 'DJ stream is live!';
|
||||||
|
nowPlayingEl.textContent = status;
|
||||||
|
} else {
|
||||||
|
nowPlayingEl.textContent = 'Stream offline - waiting for DJ...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('broadcast_stopped', () => {
|
||||||
|
const nowPlayingEl = document.getElementById('listener-now-playing');
|
||||||
|
if (nowPlayingEl) nowPlayingEl.textContent = 'Stream ended';
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
const statusEl = document.getElementById('connection-status');
|
||||||
|
if (statusEl && window.listenerAudioEnabled) {
|
||||||
|
statusEl.textContent = '[ACTIVE] Connected';
|
||||||
|
}
|
||||||
|
socket.emit('join_listener');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
const statusEl = document.getElementById('connection-status');
|
||||||
|
if (statusEl) statusEl.textContent = '[OFFLINE] Disconnected';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Enable Audio (user gesture required) ---
|
||||||
|
|
||||||
|
async function enableListenerAudio() {
|
||||||
|
console.log('[LISTENER] Enabling audio via user gesture...');
|
||||||
|
|
||||||
|
const enableAudioBtn = document.getElementById('enable-audio-btn');
|
||||||
|
const statusEl = document.getElementById('connection-status');
|
||||||
|
const audioText = enableAudioBtn ? enableAudioBtn.querySelector('.audio-text') : null;
|
||||||
|
|
||||||
|
if (audioText) audioText.textContent = 'INITIALIZING...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create AudioContext if needed
|
||||||
|
if (!listenerAudioContext) {
|
||||||
|
listenerAudioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resume audio context (CRITICAL for Chrome/Safari)
|
||||||
|
if (listenerAudioContext.state === 'suspended') {
|
||||||
|
await listenerAudioContext.resume();
|
||||||
|
console.log('[OK] Audio context resumed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Bridge Audio Element to AudioContext
|
||||||
|
if (window.listenerAudio) {
|
||||||
|
try {
|
||||||
|
if (!listenerGainNode) {
|
||||||
|
listenerGainNode = listenerAudioContext.createGain();
|
||||||
|
listenerGainNode.gain.value = 0.8;
|
||||||
|
listenerGainNode.connect(listenerAudioContext.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listenerAnalyserNode) {
|
||||||
|
listenerAnalyserNode = listenerAudioContext.createAnalyser();
|
||||||
|
listenerAnalyserNode.fftSize = 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listenerMediaElementSourceNode) {
|
||||||
|
listenerMediaElementSourceNode = listenerAudioContext.createMediaElementSource(window.listenerAudio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean single connection chain: media -> analyser -> gain -> destination
|
||||||
|
try { listenerMediaElementSourceNode.disconnect(); } catch (_) { }
|
||||||
|
try { listenerAnalyserNode.disconnect(); } catch (_) { }
|
||||||
|
|
||||||
|
listenerMediaElementSourceNode.connect(listenerAnalyserNode);
|
||||||
|
listenerAnalyserNode.connect(listenerGainNode);
|
||||||
|
|
||||||
|
window.listenerAudio._connectedToContext = true;
|
||||||
|
console.log('[OK] Connected audio element to AudioContext (with analyser)');
|
||||||
|
|
||||||
|
startListenerVUMeter();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not connect to AudioContext:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Prepare and start audio playback
|
||||||
|
if (window.listenerAudio) {
|
||||||
|
window.listenerAudio.muted = false;
|
||||||
|
window.listenerAudio.volume = 1.0;
|
||||||
|
|
||||||
|
const volEl = document.getElementById('listener-volume');
|
||||||
|
const volValue = volEl ? parseInt(volEl.value, 10) : 80;
|
||||||
|
setListenerVolume(Number.isFinite(volValue) ? volValue : 80);
|
||||||
|
|
||||||
|
const hasBufferedData = () => {
|
||||||
|
return window.listenerAudio.buffered && window.listenerAudio.buffered.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.listenerAudioEnabled = true;
|
||||||
|
|
||||||
|
if (audioText) audioText.textContent = 'STARTING...';
|
||||||
|
console.log('[PLAY] Attempting to play audio...');
|
||||||
|
|
||||||
|
const playTimeout = setTimeout(() => {
|
||||||
|
if (!hasBufferedData()) {
|
||||||
|
console.warn('[WARN] Audio play is taking a long time (buffering)...');
|
||||||
|
if (audioText) audioText.textContent = 'STILL BUFFERING...';
|
||||||
|
window.listenerAudio.load();
|
||||||
|
}
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
const playPromise = window.listenerAudio.play();
|
||||||
|
|
||||||
|
if (!hasBufferedData() && audioText) {
|
||||||
|
audioText.textContent = 'BUFFERING...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await playPromise;
|
||||||
|
clearTimeout(playTimeout);
|
||||||
|
console.log('[OK] Audio playback started successfully');
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(playTimeout);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Hide the button and update status
|
||||||
|
if (enableAudioBtn) {
|
||||||
|
enableAudioBtn.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
enableAudioBtn.style.display = 'none';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '[ACTIVE] Audio Active - Enjoy the stream';
|
||||||
|
statusEl.classList.add('glow-text');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ERROR] Failed to enable audio:', error);
|
||||||
|
const stashedBtn = document.getElementById('enable-audio-btn');
|
||||||
|
const stashedStatus = document.getElementById('connection-status');
|
||||||
|
const aText = stashedBtn ? stashedBtn.querySelector('.audio-text') : null;
|
||||||
|
|
||||||
|
if (aText) aText.textContent = 'RETRY ENABLE';
|
||||||
|
if (stashedStatus) {
|
||||||
|
let errorMsg = error.name + ': ' + error.message;
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
errorMsg = 'Browser blocked audio (NotAllowedError). Check permissions.';
|
||||||
|
} else if (error.name === 'NotSupportedError') {
|
||||||
|
errorMsg = 'MP3 stream not supported or unavailable (NotSupportedError).';
|
||||||
|
}
|
||||||
|
stashedStatus.textContent = errorMsg;
|
||||||
|
|
||||||
|
if (error.name === 'NotSupportedError') {
|
||||||
|
stashedStatus.textContent = 'MP3 stream failed. Is ffmpeg installed on the server?';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Volume ---
|
||||||
|
|
||||||
|
function setListenerVolume(value) {
|
||||||
|
if (listenerGainNode) {
|
||||||
|
listenerGainNode.gain.value = value / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Boot ---
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initListenerMode();
|
||||||
|
});
|
||||||
|
|
@ -269,7 +269,7 @@ if not os.path.exists(MUSIC_FOLDER):
|
||||||
os.makedirs(MUSIC_FOLDER)
|
os.makedirs(MUSIC_FOLDER)
|
||||||
|
|
||||||
# Helper for shared routes
|
# Helper for shared routes
|
||||||
def setup_shared_routes(app):
|
def setup_shared_routes(app, index_file='index.html'):
|
||||||
@app.route('/library.json')
|
@app.route('/library.json')
|
||||||
def get_library():
|
def get_library():
|
||||||
library = []
|
library = []
|
||||||
|
|
@ -359,7 +359,7 @@ def setup_shared_routes(app):
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return send_from_directory('.', 'index.html')
|
return send_from_directory('.', index_file)
|
||||||
|
|
||||||
@app.route('/upload', methods=['POST'])
|
@app.route('/upload', methods=['POST'])
|
||||||
def upload_file():
|
def upload_file():
|
||||||
|
|
@ -664,7 +664,7 @@ def dj_audio(data):
|
||||||
listener_app = Flask(__name__, static_folder='.', static_url_path='')
|
listener_app = Flask(__name__, static_folder='.', static_url_path='')
|
||||||
listener_app.config['SECRET_KEY'] = CONFIG_SECRET + '_listener'
|
listener_app.config['SECRET_KEY'] = CONFIG_SECRET + '_listener'
|
||||||
listener_app.config['MAX_CONTENT_LENGTH'] = CONFIG_MAX_UPLOAD_MB * 1024 * 1024
|
listener_app.config['MAX_CONTENT_LENGTH'] = CONFIG_MAX_UPLOAD_MB * 1024 * 1024
|
||||||
setup_shared_routes(listener_app)
|
setup_shared_routes(listener_app, index_file='listener.html')
|
||||||
|
|
||||||
# Block write/admin endpoints on the listener server
|
# Block write/admin endpoints on the listener server
|
||||||
@listener_app.before_request
|
@listener_app.before_request
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue