diff --git a/.env.example b/.env.example
index 41f1599..c2a9cc8 100644
--- a/.env.example
+++ b/.env.example
@@ -175,6 +175,20 @@ MEDIASESSION=true
# album art as tracks change.
DYNAMIC_META=true
+# Real-time scrolling waveform (Web Audio), shown after the listener presses
+# play. It routes playback through Web Audio, so if your STREAM_URL is on a
+# DIFFERENT origin than the site it must send an Access-Control-Allow-Origin
+# header — set WAVEFORM_STREAM_CORS=true once it does, or the waveform is
+# auto-disabled to avoid silencing the audio. Same-origin streams need nothing.
+WAVEFORM_ENABLED=false
+WAVEFORM_HEIGHT=72
+# Bar color; blank uses your THEME_ACCENT.
+WAVEFORM_COLOR=""
+# Seconds of history shown across the width before peaks scroll off.
+WAVEFORM_WINDOW_SECONDS=30
+# Set true only if a cross-origin stream sends CORS headers.
+WAVEFORM_STREAM_CORS=false
+
# -----------------------------------------------------------------------------
# Listeners / history
diff --git a/README.md b/README.md
index 83cdfa7..31625ce 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+
+
+
+
# Radio
A small, self-hostable website for an internet radio station. Point it at your
@@ -110,6 +114,27 @@ song has several Genius entries.
For a live stream the seek bar is replaced by a **LIVE** badge and an elapsed
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
+Web Audio API — an `AnalyserNode` fed by the `` element — and paints
+peaks onto a `` themed with your accent color. Tunable via
+`WAVEFORM_HEIGHT`, `WAVEFORM_COLOR`, and `WAVEFORM_WINDOW_SECONDS`.
+
+Two important caveats:
+
+- **CORS.** Web Audio can only read the audio if the stream is same-origin as
+ the site, or the stream server sends `Access-Control-Allow-Origin` (and the
+ `` uses `crossorigin`, which the page adds automatically when active).
+ If your `STREAM_URL` is on a different host (e.g. a dedicated `stream.`
+ subdomain for the iOS/HTTP-2 fix) the waveform is **auto-disabled** unless you
+ set `WAVEFORM_STREAM_CORS=true` — because routing cross-origin audio through
+ Web Audio without CORS would silence it. Add the CORS header on the stream
+ host, then flip that flag on.
+- **iOS.** Routing through Web Audio means playback then respects the iPhone's
+ ring/silent switch. It's off by default; test on-device before enabling.
+
### Offline fallback
When the now-playing provider reports the stream is offline, the player shows a
@@ -282,6 +307,16 @@ repackager and point iOS at that.
- Set `SHOW_ERRORS=false` and `DEBUG=false` in production.
- The lyrics/artwork endpoints echo back only sanitized data (plain-text lyrics, provider URLs).
+## Screenshots
+
+
+
+
+
+
+
+
+
## Credits
Broadcast with [Icecast](https://icecast.org/) and
diff --git a/assets/css/radio.css b/assets/css/radio.css
index cf90f31..53ec9b3 100644
--- a/assets/css/radio.css
+++ b/assets/css/radio.css
@@ -140,6 +140,10 @@ a:hover, a:focus { color: var(--text); text-decoration: underline; }
/* native fallback (PLAYER_STYLE=simple / noscript) */
audio[controls] { width: 100%; outline: 0; }
+/* --------------------------------------------------------------- waveform */
+.waveform { width: 100%; display: block; border-radius: 8px; }
+.waveform[hidden] { display: none; } /* [hidden] must beat display:block */
+
/* --------------------------------------------------------------- buttons */
.btn-accent {
min-width: 120px;
diff --git a/assets/js/player.js b/assets/js/player.js
index b2592c3..158db45 100644
--- a/assets/js/player.js
+++ b/assets/js/player.js
@@ -40,7 +40,8 @@
historyBody: $('history-body'),
history: $('history'),
listeners: $('listeners'),
- canvas: $('tint-canvas')
+ canvas: $('tint-canvas'),
+ waveform: $('waveform')
};
var ICON = {
@@ -179,6 +180,103 @@
}
}
+ /* ------------------------------------------------------ waveform viz */
+ // 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).
+ var waveform = (function () {
+ var wcfg = CFG.waveform || {};
+ var canvas = el.waveform;
+ 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 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);
+ var color = wcfg.color || getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e21d31';
+
+ function size() {
+ var cssW = canvas.clientWidth || (canvas.parentNode ? canvas.parentNode.clientWidth : 300) || 300;
+ var cssH = wcfg.height || 72;
+ canvas.width = Math.round(cssW * dpr);
+ canvas.height = Math.round(cssH * dpr);
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
+ }
+
+ function ensureGraph() {
+ if (audioCtx) return true;
+ if (graphFailed) return false;
+ var AC = window.AudioContext || window.webkitAudioContext;
+ if (!AC) { graphFailed = true; return false; }
+ try {
+ audioCtx = new AC();
+ source = audioCtx.createMediaElementSource(audio); // once per element
+ analyser = audioCtx.createAnalyser();
+ analyser.fftSize = 1024;
+ source.connect(analyser);
+ analyser.connect(audioCtx.destination); // keep audio audible
+ timeData = new Float32Array(analyser.fftSize);
+ } catch (e) {
+ graphFailed = true; audioCtx = null; // cross-origin blocked, etc.
+ return false;
+ }
+ return true;
+ }
+
+ function draw(ts) {
+ if (!running) return;
+ rafId = requestAnimationFrame(draw);
+ if (!analyser) return;
+ analyser.getFloatTimeDomainData(timeData);
+ var m = 0, i;
+ for (i = 0; i < timeData.length; i++) { var v = Math.abs(timeData[i]); if (v > m) m = v; }
+ if (m > runningMax) runningMax = m;
+
+ 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();
+ }
+
+ 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);
+ }
+ }
+
+ 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); }
+ }
+ function stop() {
+ running = false;
+ if (rafId) cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+
+ window.addEventListener('resize', function () { if (!canvas.hidden) size(); });
+ return { start: start, stop: stop };
+ })();
+
+ if (audio) {
+ audio.addEventListener('play', function () { waveform.start(); });
+ audio.addEventListener('pause', function () { waveform.stop(); });
+ }
+
/* --------------------------------------------------- background tinting */
function luminance(r, g, b) { return 0.299 * r + 0.587 * g + 0.114 * b; }
diff --git a/assets/repo/radio-example-01.jpg b/assets/repo/radio-example-01.jpg
new file mode 100644
index 0000000..76e1d83
Binary files /dev/null and b/assets/repo/radio-example-01.jpg differ
diff --git a/assets/repo/radio-example-02.jpg b/assets/repo/radio-example-02.jpg
new file mode 100644
index 0000000..2b3b36c
Binary files /dev/null and b/assets/repo/radio-example-02.jpg differ
diff --git a/config.php b/config.php
index a60a7c9..97397d4 100644
--- a/config.php
+++ b/config.php
@@ -226,6 +226,19 @@ if (!defined('RADIO_CONFIG_LOADED')) {
'dynamic_meta' => radio_env_bool('DYNAMIC_META', true),
],
+ // ---- Waveform visualizer --------------------------------------
+ // Real-time scrolling waveform (Web Audio) shown after the listener
+ // presses play. Off by default. It routes playback through Web Audio,
+ // so a CROSS-ORIGIN stream must send CORS headers (see WAVEFORM_STREAM_CORS)
+ // or it is auto-disabled to protect audio.
+ 'waveform' => [
+ 'enabled' => radio_env_bool('WAVEFORM_ENABLED', false),
+ 'height' => radio_env_int('WAVEFORM_HEIGHT', 72, 16),
+ '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),
+ ],
+
// ---- Listeners / extras ---------------------------------------
'features' => [
'show_listeners' => radio_env_bool('SHOW_LISTENERS', true),
@@ -274,6 +287,25 @@ function e($str) {
return htmlspecialchars((string) $str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
+/** Is the stream on the same origin as the site? (relative/unknown => yes) */
+function radio_stream_same_origin() {
+ $streamHost = parse_url(cfg('stream.url'), PHP_URL_HOST);
+ if ($streamHost === null || $streamHost === false || $streamHost === '') return true;
+ $siteHost = parse_url(cfg('site.url'), PHP_URL_HOST);
+ if ($siteHost === null || $siteHost === false || $siteHost === '') return true;
+ return strcasecmp($streamHost, $siteHost) === 0;
+}
+
+/**
+ * Whether the waveform visualizer may run: enabled AND the stream is either
+ * same-origin or the operator has confirmed the (cross-origin) stream sends
+ * CORS headers. Routing cross-origin audio through Web Audio without CORS
+ * silences it, so we auto-disable rather than risk breaking playback.
+ */
+function radio_waveform_active() {
+ return cfg('waveform.enabled') && (radio_stream_same_origin() || cfg('waveform.cors_ok'));
+}
+
/**
* The subset of config that is safe and useful to expose to the browser.
* Emitted as `window.RADIO_CONFIG` by the page.
@@ -301,5 +333,11 @@ function radio_client_config() {
'offlineName' => cfg('offline.name'),
'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'),
+ ],
];
}
diff --git a/index.php b/index.php
index bd939b8..96653f1 100644
--- a/index.php
+++ b/index.php
@@ -11,6 +11,7 @@ $showHistory = cfg('features.show_history');
$showListeners = cfg('features.show_listeners');
$rich = cfg('player.style') !== 'simple';
$hasStream = cfg('stream.url') !== '';
+$waveActive = radio_waveform_active();
?>
@@ -31,13 +32,17 @@ $hasStream = cfg('stream.url') !== '';
- = cfg('player.autoplay') ? ' autoplay' : '' ?>>
+ = $rich ? '' : ' controls' ?>= cfg('player.autoplay') ? ' autoplay' : '' ?>>
Your browser does not support the audio element.
+
+
+
+