Reviewed-on: #6
Radio
A small, self-hostable website for an internet radio station. Point it at your
stream, edit one .env file, and you get a live player with album art, a
"now playing" title, a recently-played history, listener counts, and optional
lyrics — no code changes required.
Originally built for radio.dou.bet; this version is generic so anyone can drop in their own configuration.
Features
- Custom player — play/pause, seek + volume, elapsed time, and a LIVE badge for live streams (or a plain native player with
PLAYER_STYLE=simple). - Now playing from Icecast, AzuraCast, Shoutcast, or any JSON feed, normalized server-side.
- Album art and lyrics via LRCLIB, dumb, and/or the Genius API, with an optional Last.fm art fallback — a link to the source appears in the lyrics panel.
- A single menu button (top-right) for the song history and waveform-style pickers, closing on an outside click.
- Optional "Liquid Glass" refraction effect on the menu/lyrics/playpause buttons (
THEME_LIQUID_GLASS). - Page background that recolors to the current album art (auto light/dark text).
- MediaSession integration so track info (including album, once resolved) appears in OS/lock-screen media controls.
- Configurable title cleanup (strip
(Remastered),[Explicit],.mp3, …). - Recently-played history, live listener count, and optional Matomo analytics.
- Fully themeable from
.env(CSS variables) — no CSS editing required.
How it works
browser ──▶ index.php / assets/* (the page + player)
browser ──▶ api/nowplaying.php ──▶ Icecast / AzuraCast / Shoutcast / custom
browser ──▶ api/artwork.php ──▶ dumb / Genius API / Last.fm (optional, in that order)
browser ──▶ api/lyrics.php ──▶ LRCLIB / dumb / Genius song page (optional, in that order)
The browser only ever talks to this site. PHP fetches your stream backend
server-side and returns one normalized JSON shape, which means no CORS headaches
and your internal backend address (e.g. 127.0.0.1:8000 behind nginx) is never
exposed to visitors. A typical deployment is nginx → PHP-FPM on the web box,
with nginx also proxying the audio stream from Icecast.
Requirements
- PHP 7.4+ (8.x recommended) with cURL, served by any web server (nginx + PHP-FPM, Apache, Caddy…)
- A running stream with a now-playing endpoint — Icecast, AzuraCast, Shoutcast, or any JSON feed
- (optional) a free Genius API token for album art and lyrics — or point
DUMB_URLat a dumb instance instead and skip the token entirely
Quick start
git clone https://git.dou.bet/iamdoubz/radio.git
cd radio
cp .env.example .env
# edit .env — at minimum set SITE_NAME, STREAM_URL, and NOWPLAYING_URL
php -S localhost:8000 # for a quick local test
Then open http://localhost:8000. For production, serve the folder with nginx/Apache and PHP-FPM (see the example config near the bottom).
Configuration
Everything lives in .env — see .env.example for the fully
documented list. The main groups are:
- Branding & metadata —
SITE_NAME,SITE_DESCRIPTION,SITE_KEYWORDS,SITE_URL,OG_IMAGE. - Stream —
STREAM_URL(what the browser plays) andSTREAM_TYPE. - Now playing —
NOWPLAYING_PROVIDER,NOWPLAYING_URL, and provider-specific keys (below). - Artwork & lyrics —
ARTWORK_SOURCE,LYRICS_ENABLED,GENIUS_TOKEN,USE_DUMB/DUMB_URL. - Theme & colors —
THEME_BG,THEME_ACCENT,THEME_TEXT,THEME_FONT,THEME_LIQUID_GLASS, … (drive CSS variables directly).
Now-playing providers
Set NOWPLAYING_PROVIDER and NOWPLAYING_URL:
| Provider | NOWPLAYING_URL example |
Notes |
|---|---|---|
icecast |
http://127.0.0.1:8000/status-json.xsl |
Set ICECAST_MOUNT if you run several mounts. Splits the stream title on TITLE_DELIMITER. |
azuracast |
https://azura.example.com/api/nowplaying/your_station |
Richest option — provides artist, title, album, art, and listeners natively (no Genius needed). |
shoutcast |
http://127.0.0.1:8000/stats?json=1 |
Splits the stream title on TITLE_DELIMITER. |
custom |
any URL returning JSON | Map fields with CUSTOM_ARTIST_PATH, CUSTOM_TITLE_PATH, CUSTOM_ART_PATH, etc. (dot notation, e.g. data.track.0.artist). |
TITLE_DELIMITER is how a combined Artist – Title string is split. The
original dou.bet station uses " <-|-> "; most setups use " - ".
Use TITLE_CLEANUP to strip junk from titles (e.g. .mp3, [Explicit],
(Remastered)). Separate entries with ||; set CLEANUP_IS_REGEX=true to
treat each entry as a case-insensitive regex.
Artwork & lyrics
ARTWORK_SOURCE controls where cover art comes from:
auto— use the art your provider supplies (e.g. AzuraCast); otherwise look it up (dumb/Genius, see below); otherwise show the placeholder.provider— only ever use provider art.genius— always run the dumb/Genius lookup (the name is historical — withUSE_DUMBon, dumb is tried first).none— always showFALLBACK_ART.
Within the genius/auto lookup, each tier only runs if the previous one
found nothing, so you can configure several at once:
- dumb (if
USE_DUMB=true) — no API token needed, see below. - Genius API (if
GENIUS_TOKEN/GENIUS_API_BASE64is set). - Last.fm (if
LASTFM_API_KEYis set) — also used directly whenARTWORK_SOURCE=lastfm.
Lyrics require LYRICS_ENABLED=true and follow the same fall-through idea:
- LRCLIB — free, no key, and the only source with synced (karaoke-style) timing.
- dumb (if
USE_DUMB=true). - Genius — scrapes the public song page (needs a token to find it via search first).
Set LYRICS_PREFER_ENGLISH=true to prefer the first English-language match
when a song has several Genius entries (only applies to the Genius tier).
When lyrics come from dumb or Genius, a small "Lyrics via …" link to the
source page appears under the lyrics; LRCLIB doesn't get one (it isn't a
web page).
dumb (Genius without a token)
dumb is a lightweight, self-hostable
Genius frontend/proxy — it scrapes Genius's server-rendered pages instead of
using the (Cloudflare-gated) official API, so no GENIUS_TOKEN is required.
Point DUMB_URL at a public instance
(e.g. https://dumb.ducks.party) or your own:
USE_DUMB=true
DUMB_URL="https://dumb.ducks.party"
This is HTML-scraping (dumb has no JSON API), so it's as fragile as the
existing Genius scrape — if dumb changes its page templates, dumb_search()/
dumb_song() in api/_bootstrap.php may need updating.
Player & appearance
PLAYER_STYLE—rich(custom controls, default) orsimple(native<audio controls>).THEME_DYNAMIC_BG— recolor the page background to the album art's average color, flipping text light/dark for contrast. Needs CORS-readable art to sample; it fails gracefully to your theme colors otherwise.MEDIASESSION— expose track title/artist/album/art to OS and lock-screen media controls. The album shown is whatever artwork lookup resolved (dumb/Genius/Last.fm), not just the raw stream metadata.PLAYER_AUTOPLAY— attempt autoplay (most browsers block audio autoplay until the user interacts).
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.
A single menu button (☰, top-right) opens "Song List" and "Waveform Styles" (when their features are enabled) — each toggles its own panel, and clicking outside any open panel closes it. On iOS mobile the waveform-style picker is hidden and the album art instead pulses with a fixed ambient CSS glow, since the real audio-reactive glow toggle isn't reachable there.
Liquid Glass buttons
THEME_LIQUID_GLASS=true gives the menu, lyrics, and play/pause buttons a
frosted-glass refraction effect instead of a flat THEME_ACCENT fill —
ported from nikdelvin/liquid-glass
(an SVG feDisplacementMap filter applied via backdrop-filter, no new
dependency). It weakens to a clearer, less-distorted look on hover. Browsers
without backdrop-filter: url() support (e.g. Safari) get a plain blurred-glass
fallback instead. The volume/mute button is left out on purpose — it's a bare
icon with no button chrome to skin.
Waveform visualizer
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
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:
- 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>usescrossorigin, which the page adds automatically when active). If yourSTREAM_URLis on a different host (e.g. a dedicatedstream.subdomain for the iOS/HTTP-2 fix) the waveform is auto-disabled unless you setWAVEFORM_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
distinct placeholder (OFFLINE_ART, default assets/img/offline.svg) and an
"off air" label (OFFLINE_NAME / OFFLINE_MESSAGE), and the green LIVE dot
disappears. If you set OFFLINE_STREAM_URL to a local/hosted audio file, the
player switches to it (looped by default via OFFLINE_LOOP) so pressing play
gives listeners a "we'll be right back" bed instead of silence. When the live
stream returns, the player switches back automatically — without interrupting a
listener who is already playing.
If api/nowplaying.php is unreachable or returns invalid JSON, the player also
treats that as offline (rather than freezing on the initial state). To preview
the offline state without stopping your stream, append ?offline=1 to the URL
(e.g. https://radio.example.com/?offline=1) and press play.
Custom artwork overrides
You can force specific artwork by creating config/overrides.json (copy
config/overrides.example.json). Each rule may set any combination of artist,
title, and album (all case-insensitive; artist/title are exact matches,
album is a substring match), plus the art to show. The first matching rule
wins.
{
"artwork": [
{ "artist": "Childish Gambino", "album": "Because the Internet", "art": "https://radio.example.com/assets/img/bti.gif" },
{ "artist": "BAW", "title": "CHILDHOOD MEMORIES", "art": "https://radio.example.com/assets/img/cm.jpg" },
{ "artist": "Steve Reich", "art": "https://radio.example.com/assets/img/reich.jpg" }
]
}
artist/title rules are applied immediately (no lookup needed). An album
rule targets just that album — so a Childish Gambino song from Because the
Internet gets the animated cover while his other albums keep their real art.
Because the stream only sends artist + title, an album rule needs the album to
be resolvable via Genius or Last.fm; if a track can't be resolved to that album,
add an artist + title rule for it as a fallback.
Theming
Colors and fonts are exposed as CSS variables generated from your .env, so you
can re-skin the site without editing CSS. For deeper layout changes edit
assets/css/radio.css, which uses those variables
(var(--accent), var(--bg), …).
Project structure
config.php # loads .env, applies defaults, exposes cfg()/config()
index.php # the page
partials/head.php # <head>: meta, SEO, theme variables, analytics
partials/noscript.php # no-JavaScript fallback
api/_bootstrap.php # shared helpers (config, HTTP, JSON)
api/nowplaying.php # normalizes Icecast/AzuraCast/Shoutcast/custom
api/artwork.php # optional Genius album-art lookup
api/lyrics.php # optional Genius lyrics scraper
assets/css/radio.css # themeable styles
assets/js/player.js # vanilla-JS player (no jQuery)
assets/img/ # placeholder art
.env.example # every setting, documented
nginx + PHP-FPM example
server {
server_name radio.example.com;
root /var/www/radio;
index index.php;
# Proxy the audio stream from Icecast (keeps Icecast off the public net).
# Icecast emits connection-specific headers (Upgrade/Connection/Keep-Alive/
# Transfer-Encoding) that are ILLEGAL over HTTP/2 (RFC 9113 §8.2.2). iOS
# Safari's strict HTTP/2 parser aborts the stream with "the network
# connection was lost" when it sees them; desktop browsers ignore them.
# Stripping them makes the stream play on iOS while keeping HTTP/2 site-wide.
location = /stream {
proxy_pass http://127.0.0.1:8000/your_mount;
proxy_set_header Host $http_host;
proxy_set_header Connection "";
proxy_hide_header Upgrade;
proxy_hide_header Connection;
proxy_hide_header Keep-Alive;
proxy_hide_header Proxy-Authenticate;
proxy_hide_header Proxy-Authorization;
proxy_hide_header TE;
proxy_hide_header Trailers;
proxy_hide_header Transfer-Encoding;
proxy_buffering off;
proxy_http_version 1.1;
}
location / { try_files $uri $uri/ /index.php$is_args$args; }
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
With the server-side api/nowplaying.php proxy you no longer need the old
status-json.xsl / peers.xsl nginx location blocks — PHP fetches the backend
directly via NOWPLAYING_URL.
To keep HTTP/2 for the main site but serve the stream over HTTP/1.1 for iOS (see "Playing on iOS" below), give the stream its own vhost:
# Dedicated stream host — HTTP/1.1 only (NO http2) so iOS Safari keeps the
# connection open. Needs its own TLS cert, e.g. certbot -d stream.example.com.
server {
listen 443 ssl; # deliberately no "http2"
server_name stream.example.com;
ssl_certificate /etc/letsencrypt/live/stream.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stream.example.com/privkey.pem;
location = /stream {
proxy_pass http://127.0.0.1:8000/your_mount;
proxy_set_header Host $http_host;
proxy_buffering off;
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
}
Then set STREAM_URL="https://stream.example.com/stream" and reload PHP-FPM.
Cross-origin audio playback needs no CORS headers, so nothing else changes.
Playing on iOS
iOS Safari (CoreMedia) is far pickier about live audio than desktop browsers,
and the same MP3 stream that plays on desktop can be silent on iPhone. To split
page problems from stream problems, open the raw stream URL directly in mobile
Safari (e.g. https://radio.example.com/stream): if Safari's built‑in player
won't play it either, the issue is the stream/delivery, not this site.
Checklist when a plain Icecast MP3 stream is silent on iOS:
- Strip Icecast's connection-specific headers when serving over HTTP/2.
This is the big one. Icecast sends
Upgrade/Connection/Keep-Alive/Transfer-Encodingheaders that are illegal in HTTP/2 (RFC 9113 §8.2.2); iOS Safari's strict parser aborts the stream with "the network connection was lost," while desktop browsers just ignore them. A finite file over HTTP/2 is fine (it carries none of those headers) — only the Icecast-proxied stream breaks, which is the classic "the file plays on iPhone but the live stream doesn't" symptom. Fix it with theproxy_hide_headerlines in the/streamlocation above; this keeps HTTP/2 for the whole site. (Alternative: serve the stream over HTTP/1.1 instead — drophttp2from the server block, or give the stream a dedicated HTTP/1.1-only hostname and pointSTREAM_URLat it, per the vhost above.) - Codec must be MP3 or AAC. iOS cannot decode Ogg Vorbis/Opus at all
(desktop can) — check the mount's
server_typeinstatus-json.xsl. - Don't buffer the stream — the
/streamlocation should keepproxy_buffering off;(see the nginx example above) so the first bytes reach iOS promptly; keep Icecast<burst-on-connect>1</burst-on-connect>too.
If a raw MP3 stream still resists iOS after all that, the guaranteed‑on‑iOS
option is to also publish an HLS .m3u8 of the mount via a small ffmpeg
repackager and point iOS at that.
Security notes
.envis gitignored; keep your Genius token there, never in committed code.- Set
SHOW_ERRORS=falseandDEBUG=falsein production. - The lyrics/artwork endpoints echo back only sanitized data (plain-text lyrics, provider URLs).
Screenshots
Credits
Broadcast with Icecast and Mixxx; artwork and lyrics via LRCLIB, the Genius API, and optionally dumb. The Liquid Glass button effect is ported from nikdelvin/liquid-glass (MIT). Licensed under the terms in LICENSE.


