Sidebar audio visualizer with 5 reactive styles and settings toggle

This commit is contained in:
Broque Thomas 2026-03-10 16:56:47 -07:00
parent a682f814f7
commit 40521fa499
3 changed files with 427 additions and 1 deletions

View file

@ -235,6 +235,9 @@
</div>
</div>
<!-- Sidebar Audio Visualizer (right edge, outside sidebar to avoid overflow clip) -->
<div class="sidebar-visualizer" id="sidebar-visualizer"></div>
<!-- Main Content Area -->
<div class="main-content">
<!-- Dashboard Page -->
@ -3906,6 +3909,18 @@
<label>Custom Color:</label>
<input type="color" id="accent-custom-color" value="#1db954">
</div>
<div class="form-group">
<label>Sidebar Visualizer:</label>
<select id="sidebar-visualizer-type" class="form-select">
<option value="bars">Bars</option>
<option value="wave">Wave</option>
<option value="spectrum">Spectrum</option>
<option value="mirror">Mirror</option>
<option value="equalizer">Equalizer</option>
<option value="none">None</option>
</select>
<small style="color: #888;">Audio-reactive visualizer on the sidebar edge when music is playing</small>
</div>
</div>
<!-- Database Settings -->

View file

@ -1799,6 +1799,7 @@ function clearTrack() {
renderNpQueue();
updateNpPrevNextButtons();
updateMediaSessionPlaybackState();
stopSidebarVisualizer();
if (npModalOpen) closeNowPlayingModal();
console.log('🧹 Track cleared and media player reset');
@ -1814,6 +1815,14 @@ function setPlayingState(playing) {
if (pauseIcon) pauseIcon.style.display = playing ? '' : 'none';
updateNpPlayButton();
updateMediaSessionPlaybackState();
// Sidebar audio visualizer
if (playing) {
npInitVisualizer();
startSidebarVisualizer();
} else {
stopSidebarVisualizer();
}
}
async function handlePlayPause() {
@ -3517,6 +3526,312 @@ function npStopVisualizerLoop() {
bars.forEach(bar => { bar.style.transform = 'scaleY(0.125)'; });
}
// ===============================
// SIDEBAR AUDIO VISUALIZER
// ===============================
let sidebarVizAnimFrame = null;
let sidebarVisualizerType = 'bars'; // bars | wave | spectrum | mirror | equalizer | none
const SIDEBAR_VIZ_BAR_COUNT = 32;
let _sidebarVizBuiltType = null;
function buildSidebarVizElements(type) {
const container = document.getElementById('sidebar-visualizer');
if (!container) return;
if (_sidebarVizBuiltType === type && container.children.length > 0) return;
_sidebarVizBuiltType = type;
container.innerHTML = '';
container.className = 'sidebar-visualizer';
if (type === 'bars') {
container.classList.add('viz-bars');
for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) {
const bar = document.createElement('div');
bar.className = 'sidebar-viz-bar';
container.appendChild(bar);
}
} else if (type === 'wave' || type === 'spectrum') {
container.classList.add('viz-canvas');
const canvas = document.createElement('canvas');
canvas.className = 'sidebar-viz-canvas';
canvas.width = 10;
canvas.height = 600;
container.appendChild(canvas);
} else if (type === 'mirror') {
container.classList.add('viz-mirror');
for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) {
const bar = document.createElement('div');
bar.className = 'sidebar-viz-mirror-bar';
container.appendChild(bar);
}
} else if (type === 'equalizer') {
container.classList.add('viz-equalizer');
for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) {
const wrap = document.createElement('div');
wrap.className = 'sidebar-viz-eq-wrap';
const bar = document.createElement('div');
bar.className = 'sidebar-viz-eq-bar';
const peak = document.createElement('div');
peak.className = 'sidebar-viz-eq-peak';
wrap.appendChild(bar);
wrap.appendChild(peak);
container.appendChild(wrap);
}
}
}
function startSidebarVisualizer() {
const type = sidebarVisualizerType;
if (type === 'none') return;
const container = document.getElementById('sidebar-visualizer');
if (!container) return;
buildSidebarVizElements(type);
container.classList.add('active');
if (sidebarVizAnimFrame) return;
if (!npAnalyser) return;
const bufferLength = npAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const hueStart = 200, hueRange = 160;
// Helper: average frequency bins for a given segment index
function getBinValue(i, count) {
const binsPerSeg = Math.max(1, Math.floor((bufferLength - 1) / count));
let sum = 0;
const start = i * binsPerSeg + 1;
for (let b = 0; b < binsPerSeg; b++) sum += dataArray[Math.min(start + b, bufferLength - 1)];
return (sum / binsPerSeg) / 255;
}
// ── Bars ──
if (type === 'bars') {
const bars = container.querySelectorAll('.sidebar-viz-bar');
if (bars.length === 0) return;
function drawBars() {
sidebarVizAnimFrame = requestAnimationFrame(drawBars);
npAnalyser.getByteFrequencyData(dataArray);
for (let i = 0; i < bars.length; i++) {
const value = getBinValue(i, bars.length);
const scale = Math.max(0.08, value);
const hue = (hueStart + (i / bars.length) * hueRange + value * 30) % 360;
bars[i].style.transform = `scaleX(${scale})`;
bars[i].style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`;
}
}
drawBars();
// ── Wave ──
} else if (type === 'wave') {
const canvas = container.querySelector('.sidebar-viz-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let hueOffset = 0;
function drawWave() {
sidebarVizAnimFrame = requestAnimationFrame(drawWave);
const ch = container.clientHeight;
if (ch > 0 && canvas.height !== ch) canvas.height = ch;
npAnalyser.getByteFrequencyData(dataArray);
const w = canvas.width, h = canvas.height;
if (h === 0) return;
ctx.clearRect(0, 0, w, h);
let totalEnergy = 0;
for (let i = 1; i < bufferLength; i++) totalEnergy += dataArray[i];
const avgEnergy = totalEnergy / (bufferLength - 1) / 255;
hueOffset = (hueOffset + 0.5) % 360;
const segments = 64;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
for (let i = 0; i <= segments; i++) {
const y = (i / segments) * h;
const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1);
const value = dataArray[binIdx] / 255;
const x = (w / 2) + Math.sin((i / segments) * Math.PI * 4 + Date.now() * 0.003) * value * (w - 2) * 0.4;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, `hsla(${hueOffset + 200}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`);
grad.addColorStop(0.5, `hsla(${hueOffset + 280}, 80%, 55%, ${0.3 + avgEnergy * 0.7})`);
grad.addColorStop(1, `hsla(${hueOffset + 360}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`);
ctx.strokeStyle = grad;
ctx.stroke();
ctx.lineWidth = 6;
ctx.globalAlpha = 0.15 + avgEnergy * 0.2;
ctx.stroke();
ctx.globalAlpha = 1;
}
drawWave();
// ── Spectrum (mountain/terrain fill) ──
} else if (type === 'spectrum') {
const canvas = container.querySelector('.sidebar-viz-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let hueOffset = 0;
// Smoothed values for fluid motion
const smoothed = new Float32Array(64);
function drawSpectrum() {
sidebarVizAnimFrame = requestAnimationFrame(drawSpectrum);
const ch = container.clientHeight;
if (ch > 0 && canvas.height !== ch) canvas.height = ch;
npAnalyser.getByteFrequencyData(dataArray);
const w = canvas.width, h = canvas.height;
if (h === 0) return;
ctx.clearRect(0, 0, w, h);
hueOffset = (hueOffset + 0.3) % 360;
const segments = smoothed.length;
// Smooth the frequency data
for (let i = 0; i < segments; i++) {
const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1);
const target = dataArray[binIdx] / 255;
smoothed[i] += (target - smoothed[i]) * 0.25;
}
// Draw filled mountain shape from left edge
ctx.beginPath();
ctx.moveTo(0, 0);
for (let i = 0; i <= segments; i++) {
const y = (i / segments) * h;
const value = i < segments ? smoothed[i] : smoothed[segments - 1];
const x = value * w * 0.95;
ctx.lineTo(x, y);
}
ctx.lineTo(0, h);
ctx.closePath();
// Gradient fill
const fillGrad = ctx.createLinearGradient(0, 0, 0, h);
fillGrad.addColorStop(0, `hsla(${hueOffset + 200}, 85%, 55%, 0.7)`);
fillGrad.addColorStop(0.25, `hsla(${hueOffset + 240}, 80%, 50%, 0.6)`);
fillGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 85%, 50%, 0.65)`);
fillGrad.addColorStop(0.75, `hsla(${hueOffset + 330}, 80%, 50%, 0.6)`);
fillGrad.addColorStop(1, `hsla(${hueOffset + 360}, 85%, 55%, 0.7)`);
ctx.fillStyle = fillGrad;
ctx.fill();
// Bright edge line
ctx.beginPath();
ctx.moveTo(0, 0);
for (let i = 0; i <= segments; i++) {
const y = (i / segments) * h;
const value = i < segments ? smoothed[i] : smoothed[segments - 1];
ctx.lineTo(value * w * 0.95, y);
}
const lineGrad = ctx.createLinearGradient(0, 0, 0, h);
lineGrad.addColorStop(0, `hsla(${hueOffset + 200}, 90%, 70%, 0.9)`);
lineGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 90%, 65%, 0.9)`);
lineGrad.addColorStop(1, `hsla(${hueOffset + 360}, 90%, 70%, 0.9)`);
ctx.strokeStyle = lineGrad;
ctx.lineWidth = 1.5;
ctx.stroke();
// Outer glow
ctx.lineWidth = 4;
ctx.globalAlpha = 0.2;
ctx.stroke();
ctx.globalAlpha = 1;
}
drawSpectrum();
// ── Mirror (bars from center outward) ──
} else if (type === 'mirror') {
const bars = container.querySelectorAll('.sidebar-viz-mirror-bar');
if (bars.length === 0) return;
function drawMirror() {
sidebarVizAnimFrame = requestAnimationFrame(drawMirror);
npAnalyser.getByteFrequencyData(dataArray);
const half = Math.floor(bars.length / 2);
for (let i = 0; i < half; i++) {
const value = getBinValue(i, half);
const scale = Math.max(0.06, value);
const hue = (hueStart + (i / half) * hueRange + value * 30) % 360;
const color = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`;
// Top half — mirror index from center
const topIdx = half - 1 - i;
const bottomIdx = half + i;
bars[topIdx].style.transform = `scaleX(${scale})`;
bars[topIdx].style.backgroundColor = color;
if (bottomIdx < bars.length) {
bars[bottomIdx].style.transform = `scaleX(${scale})`;
bars[bottomIdx].style.backgroundColor = color;
}
}
}
drawMirror();
// ── Equalizer (bars with falling peak indicators) ──
} else if (type === 'equalizer') {
const wraps = container.querySelectorAll('.sidebar-viz-eq-wrap');
if (wraps.length === 0) return;
const peaks = new Float32Array(wraps.length);
const peakVelocity = new Float32Array(wraps.length);
function drawEqualizer() {
sidebarVizAnimFrame = requestAnimationFrame(drawEqualizer);
npAnalyser.getByteFrequencyData(dataArray);
for (let i = 0; i < wraps.length; i++) {
const value = getBinValue(i, wraps.length);
const scale = Math.max(0.06, value);
const hue = (hueStart + (i / wraps.length) * hueRange + value * 30) % 360;
const barEl = wraps[i].querySelector('.sidebar-viz-eq-bar');
const peakEl = wraps[i].querySelector('.sidebar-viz-eq-peak');
barEl.style.transform = `scaleX(${scale})`;
barEl.style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`;
// Peak hold with gravity
if (value > peaks[i]) {
peaks[i] = value;
peakVelocity[i] = 0;
} else {
peakVelocity[i] += 0.002; // gravity
peaks[i] = Math.max(0, peaks[i] - peakVelocity[i]);
}
const peakPos = Math.max(0.06, peaks[i]);
peakEl.style.left = `${peakPos * 100}%`;
peakEl.style.backgroundColor = `hsla(${hue}, 90%, 75%, ${0.6 + peaks[i] * 0.4})`;
peakEl.style.boxShadow = `0 0 4px hsla(${hue}, 90%, 70%, ${peaks[i] * 0.5})`;
}
}
drawEqualizer();
}
}
function stopSidebarVisualizer() {
if (sidebarVizAnimFrame) {
cancelAnimationFrame(sidebarVizAnimFrame);
sidebarVizAnimFrame = null;
}
const container = document.getElementById('sidebar-visualizer');
if (container) {
container.classList.remove('active');
}
}
// Listen for visualizer type changes in settings — use isPlaying (not wasRunning)
// so switching from 'none' to a real type while music plays starts the visualizer
document.addEventListener('change', (e) => {
if (e.target.id === 'sidebar-visualizer-type') {
const newType = e.target.value;
stopSidebarVisualizer();
_sidebarVizBuiltType = null; // force rebuild for new type
sidebarVisualizerType = newType;
if (isPlaying && newType !== 'none') {
npInitVisualizer();
startSidebarVisualizer();
}
}
});
// ===============================
// RADIO MODE
// ===============================
@ -3995,6 +4310,12 @@ async function loadSettingsData() {
}
}
// Sidebar visualizer type
const vizType = settings.ui_appearance?.sidebar_visualizer || 'bars';
const vizSelect = document.getElementById('sidebar-visualizer-type');
if (vizSelect) vizSelect.value = vizType;
sidebarVisualizerType = vizType;
// Populate Logging information (read-only)
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
@ -4685,7 +5006,8 @@ async function saveSettings(quiet = false) {
},
ui_appearance: {
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954'
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954',
sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars'
},
youtube: {
cookies_browser: document.getElementById('youtube-cookies-browser').value,

View file

@ -123,6 +123,95 @@ body {
z-index: 1;
}
/* Sidebar Audio Visualizer — fixed strip on right edge of sidebar */
.sidebar-visualizer {
position: fixed;
left: 240px;
top: 0;
bottom: 0;
width: 10px;
z-index: 100;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
}
.sidebar-visualizer.active {
opacity: 1;
}
/* Type: Bars */
.sidebar-visualizer.viz-bars {
display: flex;
flex-direction: column-reverse;
gap: 2px;
padding: 16px 0;
}
.sidebar-viz-bar {
flex: 1;
border-radius: 4px;
transform: scaleX(0.1);
transform-origin: left center;
will-change: transform;
}
/* Type: Wave & Spectrum (canvas) */
.sidebar-visualizer.viz-canvas {
display: flex;
align-items: stretch;
}
.sidebar-viz-canvas {
width: 100%;
height: 100%;
}
/* Type: Mirror (bars from center) */
.sidebar-visualizer.viz-mirror {
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 0;
}
.sidebar-viz-mirror-bar {
flex: 1;
border-radius: 4px;
transform: scaleX(0.06);
transform-origin: left center;
will-change: transform;
}
/* Type: Equalizer (bars with peak hold) */
.sidebar-visualizer.viz-equalizer {
display: flex;
flex-direction: column-reverse;
gap: 2px;
padding: 16px 0;
}
.sidebar-viz-eq-wrap {
flex: 1;
position: relative;
}
.sidebar-viz-eq-bar {
width: 100%;
height: 100%;
border-radius: 4px;
transform: scaleX(0.06);
transform-origin: left center;
will-change: transform;
}
.sidebar-viz-eq-peak {
position: absolute;
top: 0;
left: 6%;
width: 2px;
height: 100%;
border-radius: 1px;
will-change: left, background-color, box-shadow;
}
@media (max-width: 768px) {
.sidebar-visualizer { display: none; }
}
/* Sidebar scrollbar webkit styling */
.sidebar::-webkit-scrollbar {
width: 6px;