344 lines
16 KiB
Markdown
344 lines
16 KiB
Markdown
<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
|
||
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](https://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 **Genius**, with an optional **Last.fm** art fallback.
|
||
- Page background that recolors to the current album art (auto light/dark text).
|
||
- **MediaSession** integration so track info 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 ──▶ Genius API (optional)
|
||
browser ──▶ api/lyrics.php ──▶ Genius song page (optional)
|
||
```
|
||
|
||
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](https://genius.com/api-clients) for album art and lyrics
|
||
|
||
## Quick start
|
||
|
||
```bash
|
||
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`](.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) and `STREAM_TYPE`.
|
||
- **Now playing** — `NOWPLAYING_PROVIDER`, `NOWPLAYING_URL`, and provider-specific keys (below).
|
||
- **Artwork & lyrics** — `ARTWORK_SOURCE`, `LYRICS_ENABLED`, `GENIUS_TOKEN`.
|
||
- **Theme & colors** — `THEME_BG`, `THEME_ACCENT`, `THEME_TEXT`, `THEME_FONT`, … (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 on Genius; otherwise show the placeholder.
|
||
- `provider` — only ever use provider art.
|
||
- `genius` — always look art up on Genius.
|
||
- `none` — always show `FALLBACK_ART`.
|
||
|
||
If Genius has no cover, set `LASTFM_API_KEY` to fall back to Last.fm artwork.
|
||
(`ARTWORK_SOURCE=lastfm` uses Last.fm only; `auto` tries Genius then Last.fm.)
|
||
|
||
Lyrics require `LYRICS_ENABLED=true`. They're looked up on
|
||
[LRCLIB](https://lrclib.net/) first — free, no key needed — and only fall back
|
||
to scraping the public Genius song page if LRCLIB has nothing **and** a Genius
|
||
token is configured. If Genius changes their page layout, `api/lyrics.php`'s
|
||
scraper may need a tweak. Set `LYRICS_PREFER_ENGLISH=true` to prefer the first
|
||
English-language match when a song has several Genius entries (only applies
|
||
to the Genius fallback).
|
||
|
||
### Player & appearance
|
||
|
||
- `PLAYER_STYLE` — `rich` (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.
|
||
- `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.
|
||
|
||
### 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.
|
||
|
||
```json
|
||
{
|
||
"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`](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
|
||
|
||
```nginx
|
||
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:
|
||
|
||
```nginx
|
||
# 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-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 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
|
||
|
||
- `.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
|
||
|
||
<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>
|
||
|
||
<p align="center">
|
||
<img src="assets/repo/radio-example-03.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
|
||
[Mixxx](https://mixxx.org/); artwork and lyrics via the
|
||
[Genius API](https://docs.genius.com/). Licensed under the terms in
|
||
[LICENSE](LICENSE).
|