[FEAT] Waveform and mobile edits #3

Merged
iamdoubz merged 5 commits from feat_waveform_edits into main 2026-07-28 10:03:59 -05:00
6 changed files with 419 additions and 37 deletions

No files matched your search

+17
View File
@@ -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
+16 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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'),
],
];
}
+40 -3
View File
@@ -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">&minus;</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>