Add listener spectrum visualizer
This commit is contained in:
@@ -413,6 +413,8 @@
|
|||||||
<div class="listener-content">
|
<div class="listener-content">
|
||||||
<div class="now-playing" id="listener-now-playing">Waiting for stream...</div>
|
<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) -->
|
<!-- Enable Audio Button (shown when autoplay is blocked) -->
|
||||||
<button class="enable-audio-btn" id="enable-audio-btn" style="display: none;"
|
<button class="enable-audio-btn" id="enable-audio-btn" style="display: none;"
|
||||||
onclick="enableListenerAudio()">
|
onclick="enableListenerAudio()">
|
||||||
|
|||||||
87
script.js
87
script.js
@@ -1545,7 +1545,67 @@ let isBroadcasting = false;
|
|||||||
let autoStartStream = false;
|
let autoStartStream = false;
|
||||||
let listenerAudioContext = null;
|
let listenerAudioContext = null;
|
||||||
let listenerGainNode = null;
|
let listenerGainNode = null;
|
||||||
|
let listenerAnalyserNode = null;
|
||||||
|
let listenerMediaElementSourceNode = null;
|
||||||
|
let listenerVuMeterRunning = false;
|
||||||
let listenerChunksReceived = 0;
|
let listenerChunksReceived = 0;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Listener uses the 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();
|
||||||
|
}
|
||||||
let currentStreamMimeType = null;
|
let currentStreamMimeType = null;
|
||||||
|
|
||||||
function getMp3FallbackUrl() {
|
function getMp3FallbackUrl() {
|
||||||
@@ -2351,17 +2411,36 @@ async function enableListenerAudio() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Bridge Audio Element to AudioContext if not already connected
|
// 3. Bridge Audio Element to AudioContext if not already connected
|
||||||
if (window.listenerAudio && !window.listenerAudio._connectedToContext) {
|
if (window.listenerAudio) {
|
||||||
try {
|
try {
|
||||||
const sourceNode = listenerAudioContext.createMediaElementSource(window.listenerAudio);
|
|
||||||
if (!listenerGainNode) {
|
if (!listenerGainNode) {
|
||||||
listenerGainNode = listenerAudioContext.createGain();
|
listenerGainNode = listenerAudioContext.createGain();
|
||||||
listenerGainNode.gain.value = 0.8;
|
listenerGainNode.gain.value = 0.8;
|
||||||
listenerGainNode.connect(listenerAudioContext.destination);
|
listenerGainNode.connect(listenerAudioContext.destination);
|
||||||
}
|
}
|
||||||
sourceNode.connect(listenerGainNode);
|
|
||||||
|
if (!listenerAnalyserNode) {
|
||||||
|
listenerAnalyserNode = listenerAudioContext.createAnalyser();
|
||||||
|
listenerAnalyserNode.fftSize = 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listenerMediaElementSourceNode) {
|
||||||
|
listenerMediaElementSourceNode = listenerAudioContext.createMediaElementSource(window.listenerAudio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a clean, single connection chain:
|
||||||
|
// media element -> analyser -> gain -> destination
|
||||||
|
try { listenerMediaElementSourceNode.disconnect(); } catch (_) { }
|
||||||
|
try { listenerAnalyserNode.disconnect(); } catch (_) { }
|
||||||
|
|
||||||
|
listenerMediaElementSourceNode.connect(listenerAnalyserNode);
|
||||||
|
listenerAnalyserNode.connect(listenerGainNode);
|
||||||
|
|
||||||
window.listenerAudio._connectedToContext = true;
|
window.listenerAudio._connectedToContext = true;
|
||||||
console.log('🔗 Connected audio element to AudioContext');
|
console.log('🔗 Connected audio element to AudioContext (with analyser)');
|
||||||
|
|
||||||
|
// Start visualizer after the graph exists
|
||||||
|
startListenerVUMeter();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('⚠️ Could not connect to AudioContext:', e.message);
|
console.warn('⚠️ Could not connect to AudioContext:', e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
14
style.css
14
style.css
@@ -1380,7 +1380,8 @@ input[type=range] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#viz-A,
|
#viz-A,
|
||||||
#viz-B {
|
#viz-B,
|
||||||
|
#viz-listener {
|
||||||
height: 80px !important;
|
height: 80px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2413,6 +2414,12 @@ input[type=range] {
|
|||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#viz-listener {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.now-playing {
|
.now-playing {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: 'Orbitron', sans-serif;
|
font-family: 'Orbitron', sans-serif;
|
||||||
@@ -2559,6 +2566,11 @@ input[type=range] {
|
|||||||
.volume-control label {
|
.volume-control label {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#viz-listener {
|
||||||
|
height: 60px !important;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide landscape prompt globally when listening-active class is present */
|
/* Hide landscape prompt globally when listening-active class is present */
|
||||||
|
|||||||
Reference in New Issue
Block a user