|
|
|
@@ -25,10 +25,12 @@ let ws = null; |
|
|
|
let reconnectTimer = null; |
|
|
|
|
|
|
|
// Viz state |
|
|
|
const NUM_BARS = 64; |
|
|
|
const peaks = new Float32Array(NUM_BARS); |
|
|
|
let lastBars = new Float32Array(NUM_BARS); |
|
|
|
let rafId = null; |
|
|
|
const NUM_BARS = 64; |
|
|
|
const peaks = new Float32Array(NUM_BARS); |
|
|
|
let lastBars = new Float32Array(NUM_BARS); |
|
|
|
let rafId = null; |
|
|
|
let lastVizAt = 0; // timestamp of last received viz frame |
|
|
|
let winampPlaying = false; |
|
|
|
|
|
|
|
// ── WebSocket ───────────────────────────────────────────────────────────────── |
|
|
|
function connect() { |
|
|
|
@@ -94,6 +96,7 @@ function applyStatus(st) { |
|
|
|
} |
|
|
|
|
|
|
|
btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; |
|
|
|
winampPlaying = st.state === 'playing'; |
|
|
|
|
|
|
|
if (typeof st.volume === 'number') { |
|
|
|
currentVolume = st.volume; |
|
|
|
@@ -193,54 +196,88 @@ $('btn-close-killist').addEventListener('click', () => |
|
|
|
function applyViz(bars) { |
|
|
|
if (!bars || bars.length === 0) return; |
|
|
|
lastBars = new Float32Array(bars); |
|
|
|
lastVizAt = performance.now(); |
|
|
|
} |
|
|
|
|
|
|
|
function renderFrame() { |
|
|
|
function renderFrame(ts = 0) { |
|
|
|
rafId = requestAnimationFrame(renderFrame); |
|
|
|
|
|
|
|
// Resize canvas to CSS size (handles window resize / DPR) |
|
|
|
const dpr = window.devicePixelRatio || 1; |
|
|
|
// Resize canvas to CSS size (handles window resize / DPR). |
|
|
|
// Setting canvas.width resets the transform, so we re-apply scale. |
|
|
|
const dpr = window.devicePixelRatio || 1; |
|
|
|
const cssW = canvas.clientWidth; |
|
|
|
const cssH = canvas.clientHeight; |
|
|
|
if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) { |
|
|
|
canvas.width = cssW * dpr; |
|
|
|
canvas.height = cssH * dpr; |
|
|
|
if (canvas.width !== Math.round(cssW * dpr) || canvas.height !== Math.round(cssH * dpr)) { |
|
|
|
canvas.width = Math.round(cssW * dpr); |
|
|
|
canvas.height = Math.round(cssH * dpr); |
|
|
|
ctx2d.scale(dpr, dpr); |
|
|
|
} |
|
|
|
|
|
|
|
const w = cssW; |
|
|
|
const h = cssH; |
|
|
|
const n = lastBars.length || NUM_BARS; |
|
|
|
if (w === 0 || h === 0) return; |
|
|
|
|
|
|
|
// Check if real viz data is fresh (< 1.5 s old) and Winamp is playing. |
|
|
|
const hasSignal = winampPlaying && (performance.now() - lastVizAt) < 1500; |
|
|
|
|
|
|
|
// Background |
|
|
|
ctx2d.fillStyle = '#000'; |
|
|
|
ctx2d.fillRect(0, 0, w, h); |
|
|
|
|
|
|
|
const n = NUM_BARS; |
|
|
|
const gap = 1; |
|
|
|
const barW = (w - gap * (n - 1)) / n; |
|
|
|
const barW = Math.max(1, (w - gap * (n - 1)) / n); |
|
|
|
|
|
|
|
for (let i = 0; i < n; i++) { |
|
|
|
const val = lastBars[i] || 0; |
|
|
|
|
|
|
|
// Peak: fast rise, slow fall (2% per frame) |
|
|
|
if (val > peaks[i]) peaks[i] = val; |
|
|
|
else peaks[i] = Math.max(0, peaks[i] - 0.012); |
|
|
|
let val; |
|
|
|
|
|
|
|
if (hasSignal) { |
|
|
|
// Real spectrum data |
|
|
|
val = lastBars[i] || 0; |
|
|
|
if (val > peaks[i]) peaks[i] = val; |
|
|
|
else peaks[i] = Math.max(0, peaks[i] - 0.012); |
|
|
|
} else { |
|
|
|
// Idle animation: slow sine "breathing" across the bars. |
|
|
|
// Amplitude fades out when paused/stopped. |
|
|
|
const phase = (ts / 1800) + (i / n) * Math.PI * 2; |
|
|
|
const breath = (Math.sin(ts / 2000) * 0.5 + 0.5) * 0.08; // 0..0.08 |
|
|
|
val = Math.max(0, Math.sin(phase) * breath); |
|
|
|
peaks[i] = Math.max(0, peaks[i] - 0.02); // let peaks fall quickly |
|
|
|
} |
|
|
|
|
|
|
|
const x = i * (barW + gap); |
|
|
|
const x = Math.round(i * (barW + gap)); |
|
|
|
const barH = val * h; |
|
|
|
|
|
|
|
// Bar colour: green (120°) → yellow (60°) → red (0°) based on amplitude |
|
|
|
const hue = Math.round((1 - val) * 120); |
|
|
|
ctx2d.fillStyle = `hsl(${hue},100%,42%)`; |
|
|
|
ctx2d.fillRect(x, h - barH, barW, barH); |
|
|
|
if (barH > 0.5) { |
|
|
|
// Bar colour: green (120°) → yellow (60°) → red (0°) |
|
|
|
const hue = Math.round((1 - val) * 120); |
|
|
|
const lit = hasSignal ? 42 : 25; // dimmer in idle |
|
|
|
ctx2d.fillStyle = `hsl(${hue},100%,${lit}%)`; |
|
|
|
ctx2d.fillRect(x, h - barH, barW, barH); |
|
|
|
} |
|
|
|
|
|
|
|
// Peak indicator — thin white line |
|
|
|
// Peak indicator |
|
|
|
if (peaks[i] > 0.02) { |
|
|
|
const py = h - peaks[i] * h - 1; |
|
|
|
ctx2d.fillStyle = 'rgba(255,255,255,0.75)'; |
|
|
|
const py = Math.round(h - peaks[i] * h) - 1; |
|
|
|
ctx2d.fillStyle = hasSignal ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.2)'; |
|
|
|
ctx2d.fillRect(x, py, barW, 2); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// "No signal" label when disconnected or stopped |
|
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) { |
|
|
|
drawLabel(ctx2d, w, h, '● NO SIGNAL', '#333'); |
|
|
|
} else if (!winampPlaying) { |
|
|
|
drawLabel(ctx2d, w, h, '▶ PLAY', '#1a3a1a'); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function drawLabel(ctx, w, h, text, color) { |
|
|
|
ctx.font = `bold ${Math.round(h * 0.22)}px monospace`; |
|
|
|
ctx.textAlign = 'center'; |
|
|
|
ctx.textBaseline = 'middle'; |
|
|
|
ctx.fillStyle = color; |
|
|
|
ctx.fillText(text, w / 2, h / 2); |
|
|
|
} |
|
|
|
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────── |
|
|
|
|