A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo

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_URL at 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 & metadataSITE_NAME, SITE_DESCRIPTION, SITE_KEYWORDS, SITE_URL, OG_IMAGE.
  • StreamSTREAM_URL (what the browser plays) and STREAM_TYPE.
  • Now playingNOWPLAYING_PROVIDER, NOWPLAYING_URL, and provider-specific keys (below).
  • Artwork & lyricsARTWORK_SOURCE, LYRICS_ENABLED, GENIUS_TOKEN, USE_DUMB / DUMB_URL.
  • Theme & colorsTHEME_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 — with USE_DUMB on, dumb is tried first).
  • none — always show FALLBACK_ART.

Within the genius/auto lookup, each tier only runs if the previous one found nothing, so you can configure several at once:

  1. dumb (if USE_DUMB=true) — no API token needed, see below.
  2. Genius API (if GENIUS_TOKEN/GENIUS_API_BASE64 is set).
  3. Last.fm (if LASTFM_API_KEY is set) — also used directly when ARTWORK_SOURCE=lastfm.

Lyrics require LYRICS_ENABLED=true and follow the same fall-through idea:

  1. LRCLIB — free, no key, and the only source with synced (karaoke-style) timing.
  2. dumb (if USE_DUMB=true).
  3. 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_STYLErich (custom controls, default) or simple (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> 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 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 builtin 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-Encoding headers 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 the proxy_hide_header lines in the /stream location above; this keeps HTTP/2 for the whole site. (Alternative: serve the stream over HTTP/1.1 instead — drop http2 from the server block, or give the stream a dedicated HTTP/1.1-only hostname and point STREAM_URL at 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_type in status-json.xsl.
  • Don't buffer the stream — the /stream location should keep proxy_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 guaranteedoniOS option is to also publish an HLS .m3u8 of the mount via a small ffmpeg repackager and point iOS at that.

Security notes

  • .env is gitignored; keep your Genius token there, never in committed code.
  • Set SHOW_ERRORS=false and DEBUG=false in production.
  • The lyrics/artwork endpoints echo back only sanitized data (plain-text lyrics, provider URLs).

Screenshots

A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo

A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo

A radio tower emitting sound waves, a music eq dancing in the wind, on air, streaming, internet radio station logo

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.

S
Description
Source code for my radio website
https://radio.dou.bet
Readme MIT
1.1 MiB
Languages
PHP 51.5%
JavaScript 36%
CSS 12.5%