From c2c5c5221d602ac1077e7511ba848bdb30ca3615 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 15:57:34 -0500 Subject: [PATCH 1/5] Hide waveform on iOS because Webkit is broken --- assets/js/player.js | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/assets/js/player.js b/assets/js/player.js index 45f9f9e..b26bb29 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -206,6 +206,14 @@ 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'; function size() { @@ -236,6 +244,14 @@ return true; } + function fail() { + graphFailed = true; + running = false; + if (rafId) cancelAnimationFrame(rafId); + rafId = null; + canvas.hidden = true; + } + function draw(ts) { if (!running) return; rafId = requestAnimationFrame(draw); @@ -245,6 +261,16 @@ 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)); @@ -270,8 +296,10 @@ 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; -- 2.34.1 From 5d3dff0144d7881fdddb7274864a0e6493a816a1 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 16:23:12 -0500 Subject: [PATCH 2/5] Add multiple waveform visualizers, make user customizable --- .env.example | 14 +++ assets/css/radio.css | 31 ++++++ assets/js/player.js | 220 +++++++++++++++++++++++++++++++++++++++---- config.php | 38 +++++++- index.php | 39 +++++++- 5 files changed, 319 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index a75f854..d6ff864 100644 --- a/.env.example +++ b/.env.example @@ -193,6 +193,20 @@ 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. Styles +# below the art (bars/line/vu/spectrum/spectrum-mirror/blob) 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. +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 diff --git a/assets/css/radio.css b/assets/css/radio.css index e9041b4..7a15754 100644 --- a/assets/css/radio.css +++ b/assets/css/radio.css @@ -52,6 +52,9 @@ a:hover, a:focus { color: var(--text); text-decoration: underline; } /* ----------------------------------------------------------- album art */ .art-wrap { position: relative; width: 100%; } +.art-ring { position: absolute; inset: 0; pointer-events: none; } +.art-ring[hidden] { display: none; } + .album-art { width: 100%; max-width: 600px; @@ -60,6 +63,7 @@ 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) * 24px) var(--accent)); opacity: 0.92; transition: opacity .2s ease-in-out, border-radius .2s ease-in-out; } @@ -143,9 +147,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; diff --git a/assets/js/player.js b/assets/js/player.js index b26bb29..72effd9 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -44,7 +44,9 @@ history: $('history'), listeners: $('listeners'), canvas: $('tint-canvas'), - waveform: $('waveform') + waveform: $('waveform'), + artRing: $('art-ring'), + wavePicker: $('waveform-picker') }; var ICON = { @@ -195,13 +197,19 @@ // 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 on + // 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; + var ring = el.artRing; if (!wcfg.active || !canvas || !audio) return { start: function () {}, stop: function () {} }; 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; @@ -215,6 +223,43 @@ 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; @@ -223,6 +268,14 @@ canvas.height = Math.round(cssH * dpr); g.setTransform(dpr, 0, 0, dpr, 0, 0); } + function sizeRing() { + if (!ring) return; + var cssW = ring.clientWidth || (ring.parentNode ? ring.parentNode.clientWidth : 300) || 300; + var cssH = ring.clientHeight || cssW; + ring.width = Math.round(cssW * dpr); + ring.height = Math.round(cssH * dpr); + ringG.setTransform(dpr, 0, 0, dpr, 0, 0); + } function ensureGraph() { if (audioCtx) return true; @@ -237,6 +290,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; @@ -250,6 +304,97 @@ 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; sizeRing(); } + var cssW = ring.width / dpr, cssH = ring.height / dpr; + var cx = cssW / 2, cy = cssH / 2; + var edge = Math.min(cssW, cssH) / 2; // roughly the album-art edge + 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) { + glowLevel += (level - glowLevel) * 0.15; + el.art.style.setProperty('--glow', glowLevel.toFixed(3)); + } + function clearGlow() { + glowLevel = 0; + el.art.style.removeProperty('--glow'); } function draw(ts) { @@ -272,25 +417,33 @@ 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() { @@ -307,7 +460,40 @@ rafId = null; } - window.addEventListener('resize', function () { if (!canvas.hidden) size(); }); + // 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) clearGlow(); + 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 }; })(); diff --git a/config.php b/config.php index 274d064..9ff3c25 100644 --- a/config.php +++ b/config.php @@ -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'), ], ]; } diff --git a/index.php b/index.php index edbe023..057cf94 100644 --- a/index.php +++ b/index.php @@ -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); ?> @@ -26,8 +29,11 @@ $waveActive = radio_waveform_active();
Album art + + + - +