[FEATURE] Add live waveform to streaming media #1
@@ -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
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="assets/icons/favicon.svg" alt="A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo">
|
||||
</p>
|
||||
|
||||
# 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 `<audio>` element — and paints
|
||||
peaks onto a `<canvas>` 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
|
||||
`<audio>` 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
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/repo/radio-example-01.jpg" alt="A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/repo/radio-example-02.jpg" alt="A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo">
|
||||
</p>
|
||||
|
||||
## Credits
|
||||
|
||||
Broadcast with [Icecast](https://icecast.org/) and
|
||||
|
||||
@@ -140,6 +140,10 @@ a:hover, a:focus { color: var(--text); text-decoration: underline; }
|
||||
/* native <audio> 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;
|
||||
|
||||
+99
-1
@@ -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; }
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
+38
@@ -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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= e(cfg('site.lang')) ?>" dir="ltr" prefix="og: http://ogp.me/ns#">
|
||||
@@ -31,13 +32,17 @@ $hasStream = cfg('stream.url') !== '';
|
||||
<div class="track-album" id="track-album"></div>
|
||||
</div>
|
||||
|
||||
<audio id="stream" preload="none"<?= $rich ? '' : ' controls' ?><?= cfg('player.autoplay') ? ' autoplay' : '' ?>>
|
||||
<audio id="stream" preload="none"<?= $waveActive ? ' crossorigin="anonymous"' : '' ?><?= $rich ? '' : ' controls' ?><?= cfg('player.autoplay') ? ' autoplay' : '' ?>>
|
||||
<?php if ($hasStream): ?>
|
||||
<source src="<?= e(cfg('stream.url')) ?>" type="<?= e(cfg('stream.type')) ?>"/>
|
||||
<?php endif; ?>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<?php if ($waveActive): ?>
|
||||
<canvas id="waveform" class="waveform" style="height:<?= (int) cfg('waveform.height') ?>px" hidden aria-hidden="true"></canvas>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($rich): ?>
|
||||
<div class="controls">
|
||||
<button id="playpause" class="playpause" type="button" aria-label="Play"></button>
|
||||
|
||||
Reference in New Issue
Block a user