Add default icons to repo, wire in changing them when stream is live and album art is found

This commit is contained in:
iamdoubz
2026-07-24 13:46:14 -05:00
parent 4ec346f020
commit 919f8c19b6
11 changed files with 188 additions and 1 deletions
+3
View File
@@ -170,6 +170,9 @@ PLAYER_STYLE="rich"
PLAYER_AUTOPLAY=false
# Expose track info to OS / lock-screen media controls (MediaSession API).
MEDIASESSION=true
# Swap the OpenGraph share image and the browser favicon to the current
# album art as tracks change.
DYNAMIC_META=true
# -----------------------------------------------------------------------------
+73
View File
@@ -165,9 +165,25 @@ server {
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; }
@@ -184,6 +200,63 @@ 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+67
View File
@@ -0,0 +1,67 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600" role="img" aria-labelledby="title desc" width="600" height="600"><title id="title">Streaming radio placeholder artwork</title><desc id="desc">Animated fallback artwork showing a radio tower, pulsing broadcast rings, and moving equalizer bars for tracks without album art.</desc><defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#08131c"></stop>
<stop offset="55%" stop-color="#0d2230"></stop>
<stop offset="100%" stop-color="#12384d"></stop>
<animate attributeName="x2" values="1;0.7;1" dur="10s" repeatCount="indefinite"></animate>
<animate attributeName="y2" values="1;0.3;1" dur="10s" repeatCount="indefinite"></animate>
</linearGradient>
<linearGradient id="glow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#67f7d4"></stop>
<stop offset="100%" stop-color="#49b8ff"></stop>
</linearGradient>
<filter id="softGlow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="8" result="blur"></feGaussianBlur>
<feMerge><feMergeNode in="blur"></feMergeNode><feMergeNode in="SourceGraphic"></feMergeNode></feMerge>
</filter>
<pattern id="grid" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#ffffff" stroke-opacity="0.06" stroke-width="1"></path>
</pattern>
<clipPath id="coverRadius"><rect x="40" y="40" width="520" height="520" rx="54" ry="54"></rect></clipPath>
</defs><rect width="600" height="600" rx="56" fill="url(#bg)"></rect><rect width="600" height="600" rx="56" fill="url(#grid)" opacity="0.7"></rect><g clip-path="url(#coverRadius)">
<circle cx="155" cy="120" r="120" fill="#67f7d4" opacity="0.08">
<animate attributeName="cx" values="155;185;155" dur="13s" repeatCount="indefinite"></animate>
<animate attributeName="cy" values="120;100;120" dur="13s" repeatCount="indefinite"></animate>
</circle>
<circle cx="488" cy="490" r="170" fill="#49b8ff" opacity="0.09">
<animate attributeName="cx" values="488;462;488" dur="15s" repeatCount="indefinite"></animate>
<animate attributeName="cy" values="490;515;490" dur="15s" repeatCount="indefinite"></animate>
</circle>
</g><g transform="translate(300 302)">
<g fill="none" stroke="url(#glow)" stroke-width="6" stroke-linecap="round" opacity="0.95" filter="url(#softGlow)">
<circle cx="0" cy="-182" r="20">
<animate attributeName="r" values="16;88" dur="2.6s" repeatCount="indefinite"></animate>
<animate attributeName="opacity" values="0.8;0" dur="2.6s" repeatCount="indefinite"></animate>
</circle>
<circle cx="0" cy="-182" r="20">
<animate attributeName="r" values="16;88" dur="2.6s" begin="0.9s" repeatCount="indefinite"></animate>
<animate attributeName="opacity" values="0.8;0" dur="2.6s" begin="0.9s" repeatCount="indefinite"></animate>
</circle>
<circle cx="0" cy="-182" r="20">
<animate attributeName="r" values="16;88" dur="2.6s" begin="1.8s" repeatCount="indefinite"></animate>
<animate attributeName="opacity" values="0.8;0" dur="2.6s" begin="1.8s" repeatCount="indefinite"></animate>
</circle>
</g>
<circle cx="0" cy="-182" r="12" fill="url(#glow)" filter="url(#softGlow)">
<animate attributeName="r" values="10;13;10" dur="1.8s" repeatCount="indefinite"></animate>
<animate attributeName="opacity" values="0.85;1;0.85" dur="1.8s" repeatCount="indefinite"></animate>
</circle>
<g stroke="#d9f7ff" stroke-width="9" stroke-linecap="round" stroke-linejoin="round">
<path d="M0 -168 L-54 -8"></path>
<path d="M0 -168 L54 -8"></path>
<path d="M-34 -74 L34 -74" opacity="0.85"></path>
<path d="M-46 -40 L46 -40" opacity="0.7"></path>
<path d="M0 -178 L0 -8" opacity="0.9"></path>
</g>
</g><g transform="translate(201 346)" fill="url(#glow)">
<rect x="0" y="60" width="18" height="18" rx="9"><animate attributeName="y" values="60;12;54;30;60" dur="1.2s" repeatCount="indefinite"></animate><animate attributeName="height" values="18;66;24;48;18" dur="1.2s" repeatCount="indefinite"></animate></rect>
<rect x="30" y="44" width="18" height="34" rx="9"><animate attributeName="y" values="44;22;50;18;44" dur="1.5s" repeatCount="indefinite"></animate><animate attributeName="height" values="34;56;28;60;34" dur="1.5s" repeatCount="indefinite"></animate></rect>
<rect x="60" y="28" width="18" height="50" rx="9"><animate attributeName="y" values="28;52;16;44;28" dur="1.1s" repeatCount="indefinite"></animate><animate attributeName="height" values="50;26;62;34;50" dur="1.1s" repeatCount="indefinite"></animate></rect>
<rect x="90" y="50" width="18" height="28" rx="9"><animate attributeName="y" values="50;20;58;36;50" dur="1.35s" repeatCount="indefinite"></animate><animate attributeName="height" values="28;58;20;42;28" dur="1.35s" repeatCount="indefinite"></animate></rect>
<rect x="120" y="18" width="18" height="60" rx="9"><animate attributeName="y" values="18;48;8;34;18" dur="1.6s" repeatCount="indefinite"></animate><animate attributeName="height" values="60;30;70;44;60" dur="1.6s" repeatCount="indefinite"></animate></rect>
<rect x="150" y="40" width="18" height="38" rx="9"><animate attributeName="y" values="40;14;46;24;40" dur="1.25s" repeatCount="indefinite"></animate><animate attributeName="height" values="38;64;32;54;38" dur="1.25s" repeatCount="indefinite"></animate></rect>
<rect x="180" y="56" width="18" height="22" rx="9"><animate attributeName="y" values="56;30;60;42;56" dur="1.45s" repeatCount="indefinite"></animate><animate attributeName="height" values="22;48;18;36;22" dur="1.45s" repeatCount="indefinite"></animate></rect>
</g><g font-family="Inter, Arial, sans-serif" text-anchor="middle">
<text x="300" y="492" font-size="28" font-weight="700" letter-spacing="0.24em" fill="#eafcff">ON AIR</text>
<text x="300" y="526" font-size="18" font-weight="500" letter-spacing="0.12em" fill="#99bbca">STREAMING RADIO</text>
</g></svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "Dou.Bet Radio",
"short_name": "DD Radio",
"icons": [
{
"src": "/assets/icons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/assets/icons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+21 -1
View File
@@ -215,13 +215,33 @@
}
/* ------------------------------------------------------------- artwork */
// Point the OpenGraph image and the favicon at the current album art.
function updateMeta(url) {
if (!CFG.dynamicMeta || !url) return;
if (el.ogImage) el.ogImage.setAttribute('content', url);
// Update any existing favicon / touch-icon links...
var icons = document.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"], link[rel="shortcut icon"]');
for (var i = 0; i < icons.length; i++) {
if (icons[i].id !== 'dyn-favicon') icons[i].setAttribute('href', url);
}
// ...and (re)create a dedicated icon link — a fresh <link> is the most
// reliable way to make browsers actually repaint the favicon.
var old = document.getElementById('dyn-favicon');
if (old && old.parentNode) old.parentNode.removeChild(old);
var link = document.createElement('link');
link.id = 'dyn-favicon';
link.rel = 'icon';
link.href = url;
document.head.appendChild(link);
}
function setArt(url, album) {
var src = url || CFG.fallbackArt;
if (el.art) {
el.art.setAttribute('src', src);
if (album) el.art.title = 'From "' + album + '"';
}
if (el.ogImage && url) el.ogImage.setAttribute('content', url);
if (url) updateMeta(url);
if (url) applyTint(url); else resetTint();
}
+3
View File
@@ -222,6 +222,8 @@ if (!defined('RADIO_CONFIG_LOADED')) {
'autoplay' => radio_env_bool('PLAYER_AUTOPLAY', false),
// Show OS/lock-screen media controls via the MediaSession API.
'mediasession' => radio_env_bool('MEDIASESSION', true),
// Swap the OpenGraph image + favicon to the current album art.
'dynamic_meta' => radio_env_bool('DYNAMIC_META', true),
],
// ---- Listeners / extras ---------------------------------------
@@ -291,6 +293,7 @@ function radio_client_config() {
'autoplay' => cfg('player.autoplay'),
'dynamicBg' => cfg('theme.dynamic_bg'),
'mediaSession' => cfg('player.mediasession'),
'dynamicMeta' => cfg('player.dynamic_meta'),
'siteName' => cfg('site.name'),
'tagline' => cfg('site.tagline'),
'offlineStreamUrl' => cfg('offline.stream_url'),