[FEAT] Waveform and mobile edits #3
No files matched your search
@@ -193,6 +193,23 @@ WAVEFORM_WINDOW_SECONDS=30
|
||||
# Set true only if a cross-origin stream sends CORS headers.
|
||||
WAVEFORM_STREAM_CORS=false
|
||||
|
||||
# Which visualizer styles are exposed to visitors, comma-separated, from:
|
||||
# bars | line | vu | spectrum | spectrum-mirror | blob | circular | glow
|
||||
# The first six render below the art and are mutually exclusive — visitors
|
||||
# pick one. circular (ring around the art) and glow (art pulses) are
|
||||
# independent toggles that can combine with a below-art style and with each
|
||||
# other. A picker appears automatically whenever more than one style is
|
||||
# listed here; with just one (or unset), it's hidden and that style is
|
||||
# simply always on, same as before this setting existed.
|
||||
# Example enabling the picker with a ring + glow option:
|
||||
# WAVEFORM_TYPES="bars,spectrum,circular,glow"
|
||||
WAVEFORM_TYPES="bars"
|
||||
# Which of the exposed styles above are active by default, comma-separated,
|
||||
# before a visitor picks anything (their choice is then remembered in their
|
||||
# browser). Blank defaults to "bars" if exposed, else the first exposed
|
||||
# below-art style.
|
||||
WAVEFORM_DEFAULT=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Listeners / history
|
||||
|
||||
@@ -118,11 +118,23 @@ counter, since seeking a live stream isn't meaningful.
|
||||
|
||||
### Waveform visualizer
|
||||
|
||||
`WAVEFORM_ENABLED=true` shows a real-time scrolling waveform that builds out as
|
||||
the stream plays (it appears only after the listener presses play). It uses the
|
||||
`WAVEFORM_ENABLED=true` shows a real-time visualizer that builds out as the
|
||||
stream plays (it appears only after the listener presses play). It uses the
|
||||
Web Audio API — an `AnalyserNode` fed by the `<audio>` element — and paints
|
||||
peaks onto a `<canvas>` themed with your accent color. Tunable via
|
||||
`WAVEFORM_HEIGHT`, `WAVEFORM_COLOR`, and `WAVEFORM_WINDOW_SECONDS`.
|
||||
onto a `<canvas>` themed with your accent color. Tunable via `WAVEFORM_HEIGHT`,
|
||||
`WAVEFORM_COLOR`, and `WAVEFORM_WINDOW_SECONDS`.
|
||||
|
||||
There are several render styles: `bars` (default, scrolling peak history),
|
||||
`line` (oscilloscope), `vu` (single level meter), `spectrum` /
|
||||
`spectrum-mirror` (frequency bars), `blob` (smoothed filled area) — these six
|
||||
render below the art and are mutually exclusive — plus `circular` (a ring
|
||||
around the play/pause button) and `glow` (the art pulses with loudness),
|
||||
which are independent toggles that can combine with a below-art style and
|
||||
with each other. Set `WAVEFORM_TYPES` (comma-separated) to expose more than one; a
|
||||
small picker then appears letting each visitor choose their own combination,
|
||||
remembered in their browser. `WAVEFORM_DEFAULT` sets what's active before a
|
||||
visitor picks anything. Leaving both unset behaves exactly like before this
|
||||
setting existed: `bars` only, no picker.
|
||||
|
||||
Two important caveats:
|
||||
|
||||
|
||||
+41
-1
@@ -60,6 +60,9 @@ a:hover, a:focus { color: var(--text); text-decoration: underline; }
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||
filter:
|
||||
drop-shadow(0 0 calc(var(--glow, 0) * 55px) var(--accent))
|
||||
drop-shadow(0 0 calc(var(--glow, 0) * 18px) var(--accent));
|
||||
opacity: 0.92;
|
||||
transition: opacity .2s ease-in-out, border-radius .2s ease-in-out;
|
||||
}
|
||||
@@ -73,6 +76,16 @@ a:hover, a:focus { color: var(--text); text-decoration: underline; }
|
||||
|
||||
/* ------------------------------------------------------------ controls */
|
||||
.controls { display: flex; justify-content: center; }
|
||||
|
||||
/* Sized well past the 64px button so the ring visualizer's ticks (drawn
|
||||
radiating outward from the button edge) have room to show. */
|
||||
.playpause-wrap {
|
||||
position: relative; width: 130px; height: 130px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.art-ring { position: absolute; inset: 0; pointer-events: none; }
|
||||
.art-ring[hidden] { display: none; }
|
||||
|
||||
.playpause {
|
||||
width: 64px; height: 64px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
@@ -143,9 +156,36 @@ a:hover, a:focus { color: var(--text); text-decoration: underline; }
|
||||
audio[controls] { width: 100%; outline: 0; }
|
||||
|
||||
/* --------------------------------------------------------------- waveform */
|
||||
.waveform-wrap { position: relative; width: 100%; }
|
||||
.waveform { width: 100%; display: block; border-radius: 8px; }
|
||||
.waveform[hidden] { display: none; } /* [hidden] must beat display:block */
|
||||
|
||||
.waveform-picker { position: absolute; top: 6px; right: 6px; z-index: 2; }
|
||||
.waveform-picker summary {
|
||||
list-style: none; padding: 4px 12px;
|
||||
background: rgba(0, 0, 0, 0.55); color: #fff;
|
||||
font-variant: small-caps; font-size: 0.8rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35); border-radius: 999px;
|
||||
cursor: pointer; backdrop-filter: blur(4px);
|
||||
transition: background .15s ease;
|
||||
}
|
||||
.waveform-picker summary::-webkit-details-marker { display: none; }
|
||||
.waveform-picker summary:hover, .waveform-picker summary:focus { background: rgba(0, 0, 0, 0.8); outline: 0; }
|
||||
.waveform-picker[open] summary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
|
||||
.waveform-picker[open] {
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 6px;
|
||||
}
|
||||
.waveform-picker fieldset {
|
||||
margin: 0; padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.85); color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35); border-radius: 10px;
|
||||
backdrop-filter: blur(4px);
|
||||
text-align: left; font-size: 0.8rem;
|
||||
}
|
||||
.waveform-picker legend { padding: 0 4px; opacity: .7; font-variant: small-caps; }
|
||||
.waveform-picker label { display: flex; align-items: center; gap: 6px; padding: 2px 0; cursor: pointer; white-space: nowrap; }
|
||||
|
||||
/* --------------------------------------------------------------- buttons */
|
||||
.btn-accent {
|
||||
min-width: 120px;
|
||||
@@ -263,7 +303,7 @@ audio[controls] { width: 100%; outline: 0; }
|
||||
@media (max-width: 575px) {
|
||||
body { background: var(--bg-mobile); }
|
||||
.player-card { padding: 16px; }
|
||||
.art-wrap, .album-art { width: 88%; }
|
||||
.art-wrap, .album-art { width: 96%; }
|
||||
.history { width: min(96vw, 420px); }
|
||||
}
|
||||
|
||||
|
||||
+271
-25
@@ -44,7 +44,9 @@
|
||||
history: $('history'),
|
||||
listeners: $('listeners'),
|
||||
canvas: $('tint-canvas'),
|
||||
waveform: $('waveform')
|
||||
waveform: $('waveform'),
|
||||
artRing: $('art-ring'),
|
||||
wavePicker: $('waveform-picker')
|
||||
};
|
||||
|
||||
var ICON = {
|
||||
@@ -195,18 +197,70 @@
|
||||
// Real-time scrolling "building" waveform via Web Audio. Only runs when the
|
||||
// server said it's safe (same-origin stream, or cross-origin + CORS), and
|
||||
// only after playback starts (AudioContext needs a user gesture anyway).
|
||||
// Several render styles share one analyser graph; the strip styles
|
||||
// (bars/line/vu/spectrum/spectrum-mirror/blob) are mutually exclusive
|
||||
// (one canvas rectangle), while circular/glow are independent overlays —
|
||||
// circular rings the play/pause button, glow lights up the album art —
|
||||
// that can combine with a strip style and with each other.
|
||||
var waveform = (function () {
|
||||
var wcfg = CFG.waveform || {};
|
||||
var canvas = el.waveform;
|
||||
if (!wcfg.active || !canvas || !audio) return { start: function () {}, stop: function () {} };
|
||||
var ring = el.artRing;
|
||||
if (!wcfg.active || !canvas || !audio) return { start: function () {}, stop: function () {}, isGlowOn: function () { return false; } };
|
||||
|
||||
var g = canvas.getContext('2d');
|
||||
var audioCtx = null, analyser = null, source = null, timeData = null, graphFailed = false;
|
||||
var ringG = ring ? ring.getContext('2d') : null;
|
||||
var audioCtx = null, analyser = null, source = null, timeData = null, freqData = null, graphFailed = false;
|
||||
var peaks = [], runningMax = 0, lastCommit = 0, rafId = null, running = false;
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var BAR = 3, GAP = 1;
|
||||
var winSec = Math.max(4, wcfg.window || 30);
|
||||
// Safari/WebKit (all iOS browsers) never delivers real analyser samples
|
||||
// for a *live* stream — getFloatTimeDomainData reads back hard zeros
|
||||
// forever, even though playback itself works fine. Wait a couple of
|
||||
// seconds for a genuine non-zero sample before ever showing the canvas;
|
||||
// if none arrives, treat it as unsupported and give up for the session
|
||||
// instead of displaying a permanently flat/broken-looking line.
|
||||
var startTs = null, sawSignal = false;
|
||||
var SILENCE_GRACE_MS = 2000;
|
||||
var color = wcfg.color || getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e21d31';
|
||||
var glowLevel = 0;
|
||||
|
||||
var STRIP_TYPES = ['bars', 'line', 'vu', 'spectrum', 'spectrum-mirror', 'blob'];
|
||||
var types = wcfg.types || ['bars'];
|
||||
var defaults = wcfg.default || [];
|
||||
|
||||
function initStrip() {
|
||||
var saved = localStorage.getItem('radio.waveformStrip');
|
||||
if (saved !== null && (saved === 'none' || types.indexOf(saved) !== -1)) return saved;
|
||||
var d = defaults.filter(function (t) { return STRIP_TYPES.indexOf(t) !== -1; })[0];
|
||||
return d || 'none';
|
||||
}
|
||||
function initToggle(key, type) {
|
||||
var saved = localStorage.getItem('radio.waveform' + key);
|
||||
if (saved !== null) return saved === '1';
|
||||
return defaults.indexOf(type) !== -1;
|
||||
}
|
||||
|
||||
var stripType = initStrip();
|
||||
var circularOn = !!ring && types.indexOf('circular') !== -1 && initToggle('Circular', 'circular');
|
||||
var glowOn = types.indexOf('glow') !== -1 && initToggle('Glow', 'glow');
|
||||
|
||||
// Groups the analyser's frequency bins into `numBuckets` log-scaled
|
||||
// bands (0..1 each) — fine resolution in the bass, coarse in the
|
||||
// treble, the standard shape for a spectrum visualizer.
|
||||
function bucketSpectrum(numBuckets) {
|
||||
analyser.getByteFrequencyData(freqData);
|
||||
var bins = freqData.length, out = new Float32Array(numBuckets);
|
||||
for (var b = 0; b < numBuckets; b++) {
|
||||
var lo = Math.floor(Math.pow(bins, b / numBuckets));
|
||||
var hi = Math.max(lo + 1, Math.floor(Math.pow(bins, (b + 1) / numBuckets)));
|
||||
var sum = 0, n = 0;
|
||||
for (var i = lo; i < hi && i < bins; i++) { sum += freqData[i]; n++; }
|
||||
out[b] = n ? (sum / n) / 255 : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function size() {
|
||||
var cssW = canvas.clientWidth || (canvas.parentNode ? canvas.parentNode.clientWidth : 300) || 300;
|
||||
@@ -215,6 +269,23 @@
|
||||
canvas.height = Math.round(cssH * dpr);
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
// Measures the parent (.playpause-wrap), not the ring canvas itself —
|
||||
// the ring is what's toggling hidden/visible, so its own clientWidth/
|
||||
// Height is an unreliable snapshot right at that transition; the
|
||||
// parent's layout never depends on the ring's own visibility.
|
||||
function sizeRing() {
|
||||
if (!ring) return null;
|
||||
var box = ring.parentNode ? ring.parentNode.getBoundingClientRect() : null;
|
||||
var cssW = (box && box.width) || 300;
|
||||
var cssH = (box && box.height) || cssW;
|
||||
var w = Math.round(cssW * dpr), h = Math.round(cssH * dpr);
|
||||
if (ring.width !== w || ring.height !== h) {
|
||||
ring.width = w;
|
||||
ring.height = h;
|
||||
ringG.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
return { w: cssW, h: cssH };
|
||||
}
|
||||
|
||||
function ensureGraph() {
|
||||
if (audioCtx) return true;
|
||||
@@ -229,6 +300,7 @@
|
||||
source.connect(analyser);
|
||||
analyser.connect(audioCtx.destination); // keep audio audible
|
||||
timeData = new Float32Array(analyser.fftSize);
|
||||
freqData = new Uint8Array(analyser.frequencyBinCount);
|
||||
} catch (e) {
|
||||
graphFailed = true; audioCtx = null; // cross-origin blocked, etc.
|
||||
return false;
|
||||
@@ -236,6 +308,114 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function fail() {
|
||||
graphFailed = true;
|
||||
running = false;
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
canvas.hidden = true;
|
||||
if (ring) ring.hidden = true;
|
||||
}
|
||||
|
||||
/* ---- strip renderers (mutually exclusive, share the canvas rect) ---- */
|
||||
function drawBars(cssW, cssH) {
|
||||
var step = BAR + GAP, mid = cssH / 2;
|
||||
for (var i = 0; i < peaks.length; i++) {
|
||||
var x = cssW - (peaks.length - i) * step;
|
||||
var h = Math.max(2, peaks[i] * cssH * 0.95);
|
||||
g.fillRect(x, mid - h / 2, BAR, h);
|
||||
}
|
||||
}
|
||||
function drawBlob(cssW, cssH) {
|
||||
if (!peaks.length) return;
|
||||
var step = BAR + GAP, mid = cssH / 2, pts = [], i;
|
||||
for (i = 0; i < peaks.length; i++) {
|
||||
var x = cssW - (peaks.length - i) * step;
|
||||
var h = Math.max(2, peaks[i] * cssH * 0.95);
|
||||
pts.push([x, mid - h / 2]);
|
||||
}
|
||||
g.beginPath();
|
||||
g.moveTo(pts[0][0], mid);
|
||||
g.lineTo(pts[0][0], pts[0][1]);
|
||||
for (i = 1; i < pts.length; i++) {
|
||||
var prev = pts[i - 1], cur = pts[i];
|
||||
g.quadraticCurveTo(prev[0], prev[1], (prev[0] + cur[0]) / 2, (prev[1] + cur[1]) / 2);
|
||||
}
|
||||
g.lineTo(pts[pts.length - 1][0], pts[pts.length - 1][1]);
|
||||
for (i = pts.length - 1; i >= 0; i--) g.lineTo(pts[i][0], mid + (mid - pts[i][1]));
|
||||
g.closePath();
|
||||
g.fill();
|
||||
}
|
||||
function drawLine(cssW, cssH) {
|
||||
var n = timeData.length, mid = cssH / 2;
|
||||
g.beginPath();
|
||||
for (var i = 0; i < n; i++) {
|
||||
var x = (i / (n - 1)) * cssW;
|
||||
var y = mid - timeData[i] * mid * 0.95;
|
||||
if (i === 0) g.moveTo(x, y); else g.lineTo(x, y);
|
||||
}
|
||||
g.lineWidth = 2;
|
||||
g.strokeStyle = color;
|
||||
g.stroke();
|
||||
}
|
||||
function drawVu(cssW, cssH, level) {
|
||||
g.fillRect(0, 0, Math.max(2, level * cssW), cssH);
|
||||
}
|
||||
function drawSpectrum(cssW, cssH, mirror) {
|
||||
var step = BAR + GAP;
|
||||
var n = Math.max(1, Math.floor(cssW / step));
|
||||
var buckets = bucketSpectrum(n);
|
||||
for (var i = 0; i < n; i++) {
|
||||
var x = i * step, v = buckets[i];
|
||||
if (mirror) {
|
||||
var half = Math.max(1, v * (cssH / 2) * 0.95);
|
||||
g.fillRect(x, cssH / 2 - half, BAR, half * 2);
|
||||
} else {
|
||||
var h = Math.max(2, v * cssH * 0.95);
|
||||
g.fillRect(x, cssH - h, BAR, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- art overlays (independent, can combine with a strip + each other) */
|
||||
function drawRing() {
|
||||
if (!ring || !ringG) return;
|
||||
if (ring.hidden) ring.hidden = false;
|
||||
var dims = sizeRing();
|
||||
if (!dims) return;
|
||||
var cssW = dims.w, cssH = dims.h;
|
||||
var cx = cssW / 2, cy = cssH / 2;
|
||||
// Canvas is deliberately larger than the button (see .playpause-wrap)
|
||||
// so ticks have room to radiate outward from the button's own edge.
|
||||
var btn = el.playpause;
|
||||
var edge = btn ? btn.offsetWidth / 2 : Math.min(cssW, cssH) / 2;
|
||||
var n = 40;
|
||||
var buckets = bucketSpectrum(n);
|
||||
ringG.clearRect(0, 0, cssW, cssH);
|
||||
ringG.strokeStyle = color;
|
||||
ringG.lineWidth = 3;
|
||||
for (var i = 0; i < n; i++) {
|
||||
var a = (i / n) * Math.PI * 2;
|
||||
var len = 6 + buckets[i] * 24;
|
||||
ringG.beginPath();
|
||||
ringG.moveTo(cx + Math.cos(a) * edge, cy + Math.sin(a) * edge);
|
||||
ringG.lineTo(cx + Math.cos(a) * (edge + len), cy + Math.sin(a) * (edge + len));
|
||||
ringG.stroke();
|
||||
}
|
||||
}
|
||||
function applyGlow(level) {
|
||||
// Typical (non-clipping) audio peaks rarely exceed ~0.3-0.4, which
|
||||
// read as barely-there on the CSS side — boost before smoothing so
|
||||
// normal playback volume is actually visible, not just near-clipping peaks.
|
||||
var target = Math.min(1, level * 2.2);
|
||||
glowLevel += (target - glowLevel) * 0.15;
|
||||
el.art.style.setProperty('--glow', glowLevel.toFixed(3));
|
||||
}
|
||||
function clearGlow() {
|
||||
glowLevel = 0;
|
||||
el.art.style.removeProperty('--glow');
|
||||
}
|
||||
|
||||
function draw(ts) {
|
||||
if (!running) return;
|
||||
rafId = requestAnimationFrame(draw);
|
||||
@@ -245,33 +425,53 @@
|
||||
for (i = 0; i < timeData.length; i++) { var v = Math.abs(timeData[i]); if (v > m) m = v; }
|
||||
if (m > runningMax) runningMax = m;
|
||||
|
||||
if (startTs === null) startTs = ts;
|
||||
if (m > 0 && !sawSignal) {
|
||||
sawSignal = true;
|
||||
if (canvas.hidden) { canvas.hidden = false; size(); }
|
||||
} else if (!sawSignal && ts - startTs >= SILENCE_GRACE_MS) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
if (!sawSignal) return; // still in the grace window — nothing to draw yet
|
||||
|
||||
var cssW = canvas.width / dpr, cssH = canvas.height / dpr;
|
||||
var step = BAR + GAP;
|
||||
var maxBars = Math.max(1, Math.floor(cssW / step));
|
||||
var interval = (winSec * 1000) / maxBars;
|
||||
if (!lastCommit) lastCommit = ts;
|
||||
if (ts - lastCommit >= interval) {
|
||||
lastCommit = ts;
|
||||
peaks.push(runningMax);
|
||||
runningMax = 0;
|
||||
while (peaks.length > maxBars) peaks.shift();
|
||||
var maxBars = Math.max(1, Math.floor(cssW / (BAR + GAP)));
|
||||
|
||||
if (stripType === 'bars' || stripType === 'blob') {
|
||||
var interval = (winSec * 1000) / maxBars;
|
||||
if (!lastCommit) lastCommit = ts;
|
||||
if (ts - lastCommit >= interval) {
|
||||
lastCommit = ts;
|
||||
peaks.push(runningMax);
|
||||
runningMax = 0;
|
||||
while (peaks.length > maxBars) peaks.shift();
|
||||
}
|
||||
}
|
||||
|
||||
g.clearRect(0, 0, cssW, cssH);
|
||||
g.fillStyle = color;
|
||||
var mid = cssH / 2;
|
||||
for (i = 0; i < peaks.length; i++) {
|
||||
var x = cssW - (peaks.length - i) * step;
|
||||
var h = Math.max(2, peaks[i] * cssH * 0.95);
|
||||
g.fillRect(x, mid - h / 2, BAR, h);
|
||||
switch (stripType) {
|
||||
case 'bars': drawBars(cssW, cssH); break;
|
||||
case 'blob': drawBlob(cssW, cssH); break;
|
||||
case 'line': drawLine(cssW, cssH); break;
|
||||
case 'vu': drawVu(cssW, cssH, m); break;
|
||||
case 'spectrum': drawSpectrum(cssW, cssH, false); break;
|
||||
case 'spectrum-mirror': drawSpectrum(cssW, cssH, true); break;
|
||||
// 'none': nothing drawn, canvas stays cleared
|
||||
}
|
||||
|
||||
if (circularOn) drawRing(); else if (ring && !ring.hidden) ring.hidden = true;
|
||||
if (glowOn) applyGlow(m); else if (glowLevel) clearGlow();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!ensureGraph()) return;
|
||||
if (audioCtx.state === 'suspended' && audioCtx.resume) audioCtx.resume();
|
||||
if (canvas.hidden) { canvas.hidden = false; size(); }
|
||||
if (!running) { running = true; lastCommit = 0; rafId = requestAnimationFrame(draw); }
|
||||
if (!running) {
|
||||
running = true; lastCommit = 0; startTs = null; sawSignal = false;
|
||||
rafId = requestAnimationFrame(draw);
|
||||
}
|
||||
}
|
||||
function stop() {
|
||||
running = false;
|
||||
@@ -279,8 +479,46 @@
|
||||
rafId = null;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () { if (!canvas.hidden) size(); });
|
||||
return { start: start, stop: stop };
|
||||
// Picker: sync initial UI to the resolved state (localStorage isn't
|
||||
// known server-side), then react to changes — no reload needed, the
|
||||
// running draw() loop picks up new state next frame.
|
||||
var picker = el.wavePicker;
|
||||
if (picker) {
|
||||
var stripInputs = picker.querySelectorAll('input[name="wave-strip"]');
|
||||
for (var si = 0; si < stripInputs.length; si++) {
|
||||
stripInputs[si].checked = (stripInputs[si].value === stripType);
|
||||
}
|
||||
var circInput = document.getElementById('wave-circular');
|
||||
if (circInput) circInput.checked = circularOn;
|
||||
var glowInput = document.getElementById('wave-glow');
|
||||
if (glowInput) glowInput.checked = glowOn;
|
||||
|
||||
picker.addEventListener('change', function (e) {
|
||||
if (e.target.name === 'wave-strip') {
|
||||
stripType = e.target.value;
|
||||
peaks = []; // fresh history instead of showing stale bars/blob data
|
||||
localStorage.setItem('radio.waveformStrip', stripType);
|
||||
} else if (e.target.id === 'wave-circular') {
|
||||
circularOn = e.target.checked;
|
||||
localStorage.setItem('radio.waveformCircular', circularOn ? '1' : '0');
|
||||
} else if (e.target.id === 'wave-glow') {
|
||||
glowOn = e.target.checked;
|
||||
if (glowOn) {
|
||||
if (el.art && el.art.src) applyTint(el.art.src); // refresh --text for the new state
|
||||
} else {
|
||||
clearGlow();
|
||||
if (!CFG.dynamicBg) document.documentElement.style.removeProperty('--text'); // glow was the only reason it was set
|
||||
}
|
||||
localStorage.setItem('radio.waveformGlow', glowOn ? '1' : '0');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
if (!canvas.hidden) size();
|
||||
if (ring && !ring.hidden) sizeRing();
|
||||
});
|
||||
return { start: start, stop: stop, isGlowOn: function () { return glowOn; } };
|
||||
})();
|
||||
|
||||
if (audio) {
|
||||
@@ -298,7 +536,13 @@
|
||||
}
|
||||
|
||||
function applyTint(url) {
|
||||
if (!CFG.dynamicBg || !el.canvas || !url) return;
|
||||
// Full-page tint needs THEME_DYNAMIC_BG; but the glow style washes the
|
||||
// album art in --accent regardless of that setting, so on a dark art
|
||||
// it still needs the same computed-background -> white-text swap even
|
||||
// when the page background itself isn't being tinted.
|
||||
var wantBg = !!CFG.dynamicBg;
|
||||
var wantText = wantBg || waveform.isGlowOn();
|
||||
if (!wantText || !el.canvas || !url) return;
|
||||
// Sample with a separate CORS image so the *visible* art still shows even
|
||||
// if the host blocks cross-origin canvas reads.
|
||||
var img = new Image();
|
||||
@@ -313,9 +557,11 @@
|
||||
var r = 0, g = 0, b = 0, n = 0;
|
||||
for (var i = 0; i < data.length; i += 4 * 5) { r += data[i]; g += data[i+1]; b += data[i+2]; n++; }
|
||||
r = Math.round(r / n); g = Math.round(g / n); b = Math.round(b / n);
|
||||
var rgb = 'rgb(' + r + ',' + g + ',' + b + ')';
|
||||
document.body.style.backgroundColor = rgb;
|
||||
if (el.card) el.card.style.backgroundColor = rgb;
|
||||
if (wantBg) {
|
||||
var rgb = 'rgb(' + r + ',' + g + ',' + b + ')';
|
||||
document.body.style.backgroundColor = rgb;
|
||||
if (el.card) el.card.style.backgroundColor = rgb;
|
||||
}
|
||||
document.documentElement.style.setProperty('--text', luminance(r, g, b) > 140 ? '#111' : '#fff');
|
||||
} catch (e) { /* cross-origin tainted canvas — skip tint */ }
|
||||
};
|
||||
|
||||
+34
-4
@@ -237,6 +237,18 @@ if (!defined('RADIO_CONFIG_LOADED')) {
|
||||
'color' => radio_env('WAVEFORM_COLOR', ''), // empty => theme accent
|
||||
'window' => radio_env_int('WAVEFORM_WINDOW_SECONDS', 30, 4),
|
||||
'cors_ok' => radio_env_bool('WAVEFORM_STREAM_CORS', false),
|
||||
// Which visualizer styles are exposed to visitors at all (comma
|
||||
// list). 'bars'/'line'/'vu'/'spectrum'/'spectrum-mirror'/'blob'
|
||||
// compete for the strip below the art (one at a time); 'circular'
|
||||
// (ring around the art) and 'glow' (art pulses) can each combine
|
||||
// with a strip choice and with each other.
|
||||
'types' => (function () {
|
||||
$known = ['bars', 'line', 'vu', 'spectrum', 'spectrum-mirror', 'blob', 'circular', 'glow'];
|
||||
$listed = array_values(array_filter(array_map('trim', explode(',', radio_env('WAVEFORM_TYPES', 'bars')))));
|
||||
$types = array_values(array_intersect($known, $listed));
|
||||
return $types ?: ['bars'];
|
||||
})(),
|
||||
'default' => null, // resolved just below, once 'types' is known
|
||||
],
|
||||
|
||||
// ---- Listeners / extras ---------------------------------------
|
||||
@@ -264,6 +276,22 @@ if (!defined('RADIO_CONFIG_LOADED')) {
|
||||
],
|
||||
];
|
||||
|
||||
// Resolve the default active waveform type(s): explicit WAVEFORM_DEFAULT
|
||||
// filtered down to what's actually exposed, else 'bars' if exposed, else
|
||||
// the first exposed strip type, else nothing (overlays only, if any).
|
||||
$defaultListed = array_values(array_filter(array_map('trim', explode(',', radio_env('WAVEFORM_DEFAULT', '')))));
|
||||
$default = array_values(array_intersect($config['waveform']['types'], $defaultListed));
|
||||
if (!$default) {
|
||||
if (in_array('bars', $config['waveform']['types'], true)) {
|
||||
$default = ['bars'];
|
||||
} else {
|
||||
$stripTypes = ['bars', 'line', 'vu', 'spectrum', 'spectrum-mirror', 'blob'];
|
||||
$firstStrip = array_values(array_intersect($config['waveform']['types'], $stripTypes));
|
||||
$default = $firstStrip ? [$firstStrip[0]] : [];
|
||||
}
|
||||
}
|
||||
$config['waveform']['default'] = $default;
|
||||
|
||||
$GLOBALS['__RADIO_CONFIG'] = $config;
|
||||
date_default_timezone_set($config['runtime']['timezone']);
|
||||
}
|
||||
@@ -338,10 +366,12 @@ function radio_client_config() {
|
||||
'offlineMessage' => cfg('offline.message'),
|
||||
'offlineLoop' => cfg('offline.loop'),
|
||||
'waveform' => [
|
||||
'active' => radio_waveform_active(),
|
||||
'height' => cfg('waveform.height'),
|
||||
'color' => cfg('waveform.color'),
|
||||
'window' => cfg('waveform.window'),
|
||||
'active' => radio_waveform_active(),
|
||||
'height' => cfg('waveform.height'),
|
||||
'color' => cfg('waveform.color'),
|
||||
'window' => cfg('waveform.window'),
|
||||
'types' => cfg('waveform.types'),
|
||||
'default' => cfg('waveform.default'),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -12,6 +12,9 @@ $showListeners = cfg('features.show_listeners');
|
||||
$rich = cfg('player.style') !== 'simple';
|
||||
$hasStream = cfg('stream.url') !== '';
|
||||
$waveActive = radio_waveform_active();
|
||||
$waveTypes = cfg('waveform.types');
|
||||
$showWavePicker = $waveActive && count($waveTypes) > 1;
|
||||
$showArtRing = $waveActive && in_array('circular', $waveTypes, true) && $rich;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= e(cfg('site.lang')) ?>" dir="ltr" prefix="og: http://ogp.me/ns#">
|
||||
@@ -27,7 +30,7 @@ $waveActive = radio_waveform_active();
|
||||
<img id="art" class="album-art" src="<?= e($fallbackArt) ?>"
|
||||
alt="Album art" title="<?= e(cfg('site.name')) ?>"/>
|
||||
<?php if ($lyricsEnabled): ?>
|
||||
<button id="lyrics-toggle" class="lyrics-toggle" type="button" hidden aria-pressed="false">Lyrics</button>
|
||||
<button id="lyrics-toggle" class="lyrics-toggle" type="button" hidden aria-pressed="false">🎶</button>
|
||||
<div id="lyrics-sync" class="lyrics-sync" hidden>
|
||||
<button type="button" data-adj="-0.5" aria-label="Lyrics earlier">−</button>
|
||||
<span id="lyrics-offset">0.0s</span>
|
||||
@@ -53,12 +56,46 @@ $waveActive = radio_waveform_active();
|
||||
</audio>
|
||||
|
||||
<?php if ($waveActive): ?>
|
||||
<canvas id="waveform" class="waveform" style="height:<?= (int) cfg('waveform.height') ?>px" hidden aria-hidden="true"></canvas>
|
||||
<div class="waveform-wrap">
|
||||
<canvas id="waveform" class="waveform" style="height:<?= (int) cfg('waveform.height') ?>px" hidden aria-hidden="true"></canvas>
|
||||
<?php if ($showWavePicker): ?>
|
||||
<details class="waveform-picker" id="waveform-picker">
|
||||
<summary>Style</summary>
|
||||
<fieldset>
|
||||
<legend>Below art</legend>
|
||||
<?php
|
||||
$stripLabels = ['none' => 'None', 'bars' => 'Bars', 'line' => 'Line', 'vu' => 'VU',
|
||||
'spectrum' => 'Spectrum', 'spectrum-mirror' => 'Spectrum (mirror)', 'blob' => 'Blob'];
|
||||
foreach ($stripLabels as $val => $label):
|
||||
if ($val !== 'none' && !in_array($val, $waveTypes, true)) continue;
|
||||
?>
|
||||
<label><input type="radio" name="wave-strip" value="<?= e($val) ?>"/> <?= e($label) ?></label>
|
||||
<?php endforeach; ?>
|
||||
</fieldset>
|
||||
<?php if ($showArtRing || in_array('glow', $waveTypes, true)): ?>
|
||||
<fieldset>
|
||||
<legend>On art</legend>
|
||||
<?php if ($showArtRing): ?>
|
||||
<label><input type="checkbox" id="wave-circular"/> Ring</label>
|
||||
<?php endif; ?>
|
||||
<?php if (in_array('glow', $waveTypes, true)): ?>
|
||||
<label><input type="checkbox" id="wave-glow"/> Glow</label>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
<?php endif; ?>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($rich): ?>
|
||||
<div class="controls">
|
||||
<button id="playpause" class="playpause" type="button" aria-label="Play"></button>
|
||||
<div class="playpause-wrap">
|
||||
<button id="playpause" class="playpause" type="button" aria-label="Play"></button>
|
||||
<?php if ($showArtRing): ?>
|
||||
<canvas id="art-ring" class="art-ring" hidden aria-hidden="true"></canvas>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seek-row">
|
||||
<span class="time" id="current-time">00:00</span>
|
||||
|
||||
Reference in new issue
Block a user