Files
radio/README.md
T

344 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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).