If stream offline have a fallback source if configured

This commit is contained in:
iamdoubz committed 2026-07-24 12:06:48 -05:00
1 parent bac13da757
commit e978ab722d
5 files changed
+204 -9

No files matched your search

+16
View File
@@ -52,6 +52,22 @@ STREAM_URL="https://radio.example.com/stream"
STREAM_TYPE="audio/mpeg"
# -----------------------------------------------------------------------------
# Offline fallback (used when now-playing reports the stream is offline)
# -----------------------------------------------------------------------------
# Optional local/hosted audio to play when the live stream is down (looped like
# a "we'll be right back" bed). Leave blank to just show the offline art.
OFFLINE_STREAM_URL=""
# e.g. OFFLINE_STREAM_URL="assets/audio/offline.mp3"
OFFLINE_STREAM_TYPE="audio/mpeg"
OFFLINE_LOOP=true
# A distinct placeholder image shown while offline.
OFFLINE_ART="assets/img/offline.svg"
# Text shown in place of the track name / artist while offline.
OFFLINE_NAME="Off air"
OFFLINE_MESSAGE="We'll be right back"
# -----------------------------------------------------------------------------
# Now playing (metadata the SERVER fetches, then normalizes for the browser)
# -----------------------------------------------------------------------------
+129
View File
@@ -0,0 +1,129 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600" role="img" aria-labelledby="title desc">
<title id="title">Offline radio placeholder artwork</title>
<desc id="desc">Funny animated offline artwork showing a radio tower on fire, smoke, OFFLINE text, and tiny stick figures running around in panic.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#13090d"/>
<stop offset="55%" stop-color="#2a1115"/>
<stop offset="100%" stop-color="#3a1711"/>
</linearGradient>
<linearGradient id="ember" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffe27a"/>
<stop offset="45%" stop-color="#ff9c3a"/>
<stop offset="100%" stop-color="#ff4e2f"/>
</linearGradient>
<linearGradient id="steel" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e8edf1"/>
<stop offset="100%" stop-color="#9db2c1"/>
</linearGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="6" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></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.05" stroke-width="1"/>
</pattern>
<clipPath id="coverRadius"><rect x="40" y="40" width="520" height="520" rx="54" ry="54"/></clipPath>
<g id="runner">
<circle cx="0" cy="-10" r="5" fill="#fff0d2"/>
<path d="M0 -4 L0 12 M0 3 L-10 9 M0 3 L9 -6 M0 12 L-9 24 M0 12 L10 21" fill="none" stroke="#fff0d2" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g id="flame">
<path d="M0 28 C-18 10 -14 -12 0 -32 C8 -16 26 -4 18 15 C15 24 10 29 0 32 C-8 30 -13 26 -16 18 C-20 8 -13 1 -7 -7 C-4 0 2 8 0 28 Z" fill="url(#ember)" filter="url(#glow)">
<animateTransform attributeName="transform" type="scale" values="0.92 0.92;1.05 1.1;0.95 0.98;0.92 0.92" dur="0.8s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.95;1;0.9;0.95" dur="0.8s" repeatCount="indefinite"/>
</path>
</g>
<g id="smokePuff">
<circle cx="0" cy="0" r="18" fill="#bcc1ca" opacity="0.26">
<animate attributeName="cy" values="0;-42;-86" dur="3.2s" repeatCount="indefinite"/>
<animate attributeName="r" values="14;22;30" dur="3.2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.28;0.18;0" dur="3.2s" repeatCount="indefinite"/>
</circle>
</g>
</defs>
<rect width="600" height="600" rx="56" fill="url(#bg)"/>
<rect width="600" height="600" rx="56" fill="url(#grid)" opacity="0.7"/>
<g clip-path="url(#coverRadius)">
<circle cx="132" cy="118" r="130" fill="#ff7e47" opacity="0.08">
<animate attributeName="cx" values="132;154;132" dur="9s" repeatCount="indefinite"/>
</circle>
<circle cx="490" cy="500" r="180" fill="#ffb04f" opacity="0.07">
<animate attributeName="cy" values="500;474;500" dur="11s" repeatCount="indefinite"/>
</circle>
</g>
<g transform="translate(300 300)">
<g stroke="url(#steel)" stroke-width="9" stroke-linecap="round" stroke-linejoin="round">
<path d="M0 -160 L-58 10"/>
<path d="M0 -160 L58 10"/>
<path d="M-35 -78 L35 -78" opacity="0.9"/>
<path d="M-48 -40 L48 -40" opacity="0.82"/>
<path d="M0 -170 L0 10" opacity="0.95"/>
</g>
<g transform="translate(0 -184)">
<circle r="11" fill="#ffd08a" filter="url(#glow)">
<animate attributeName="r" values="9;12;9" dur="0.9s" repeatCount="indefinite"/>
</circle>
<use href="#flame" x="0" y="-6"/>
<use href="#smokePuff" x="-16" y="-32"/>
<use href="#smokePuff" x="10" y="-50">
<animate attributeName="opacity" values="0;0;1" dur="3.2s" repeatCount="indefinite"/>
</use>
<use href="#smokePuff" x="24" y="-24">
<animate attributeName="opacity" values="0;0;0.9" dur="3.2s" begin="1.1s" repeatCount="indefinite"/>
</use>
</g>
<g fill="none" stroke="#ff8155" stroke-width="5" stroke-linecap="round" opacity="0.7" filter="url(#glow)">
<path d="M-102 -164 q28 -18 56 0">
<animate attributeName="opacity" values="0.3;0.75;0.3" dur="1.2s" repeatCount="indefinite"/>
</path>
<path d="M46 -164 q28 -18 56 0">
<animate attributeName="opacity" values="0.75;0.3;0.75" dur="1.2s" repeatCount="indefinite"/>
</path>
</g>
</g>
<g font-family="Inter, Arial, sans-serif" text-anchor="middle">
<text x="300" y="444" font-size="42" font-weight="900" letter-spacing="0.18em" fill="#fff1db">OFFLINE</text>
<text x="300" y="478" font-size="16" font-weight="600" letter-spacing="0.12em" fill="#ffb08d">tiny technicians have abandoned the scene</text>
</g>
<g>
<use href="#runner" transform="translate(138 502)">
<animateTransform attributeName="transform" type="translate" values="138 520;188 514;138 520" dur="0.9s" repeatCount="indefinite"/>
</use>
<text x="112" y="500" font-size="22" fill="#fff3d4">!</text>
<use href="#runner" transform="translate(222 506) scale(-1 1)">
<animateTransform attributeName="transform" type="translate" values="222 534;182 528;222 534" dur="1s" repeatCount="indefinite"/>
</use>
<text x="244" y="510" font-size="18" fill="#fff3d4">!</text>
<use href="#runner" transform="translate(372 504)">
<animateTransform attributeName="transform" type="translate" values="372 526;422 518;372 526" dur="0.85s" repeatCount="indefinite"/>
</use>
<text x="350" y="503" font-size="18" fill="#fff3d4">!</text>
<use href="#runner" transform="translate(462 508) scale(-1 1)">
<animateTransform attributeName="transform" type="translate" values="462 536;422 530;462 536" dur="1.1s" repeatCount="indefinite"/>
</use>
<text x="482" y="512" font-size="22" fill="#fff3d4">!</text>
</g>
<g fill="#2b120d" opacity="0.95">
<ellipse cx="300" cy="552" rx="232" ry="22"/>
</g>
<g opacity="0.95">
<use href="#flame" x="82" y="204" transform="scale(0.75)"/>
<use href="#flame" x="482" y="238" transform="scale(0.62)"/>
<use href="#flame" x="414" y="418" transform="scale(0.7)"/>
<use href="#flame" x="162" y="340" transform="scale(0.58)"/>
<use href="#flame" x="292" y="246" transform="scale(0.52)"/>
<use href="#flame" x="528" y="344" transform="scale(0.56)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.4 KiB

+41 -8
View File
@@ -75,6 +75,26 @@
var audio = el.audio;
function isLive() { return audio && !isFinite(audio.duration); }
// Source management: switch between the live stream and the offline fallback
// without yanking audio out from under a listener who is already playing.
var liveSourceUrl = (function () {
if (!audio) return '';
var s = audio.querySelector('source');
return s ? s.getAttribute('src') : (audio.getAttribute('src') || '');
})();
var currentSourceUrl = liveSourceUrl;
var offlineMode = false;
function setSource(url, loop) {
if (!audio || !url || url === currentSourceUrl) { if (audio) audio.loop = !!loop; return; }
var wasPlaying = !audio.paused && !audio.ended;
currentSourceUrl = url;
audio.src = url;
audio.loop = !!loop;
audio.load();
if (wasPlaying) audio.play().catch(function () {});
}
if (audio && el.playpause) {
el.playpause.innerHTML = ICON.play;
@@ -107,11 +127,11 @@
});
}
// Seek / time. Live streams have no finite duration: show a LIVE badge and
// an elapsed-time counter instead of a scrubber.
// Seek / time. Live streams have no finite duration, so disable the
// scrubber and hide the total-duration (the LIVE badge itself is driven by
// the broadcast state from the now-playing poll, not the audio element).
function reflectDuration() {
var live = isLive();
if (el.liveBadge) el.liveBadge.hidden = !live;
if (el.seek) el.seek.disabled = live || isNaN(audio.duration);
if (el.totTime) el.totTime.style.visibility = live ? 'hidden' : 'visible';
}
@@ -309,20 +329,33 @@
}
function goOffline() {
if (el.name) el.name.textContent = siteName;
if (el.artist) el.artist.textContent = CFG.tagline || '';
if (el.album) el.album.textContent = '';
if (el.title) el.title.textContent = siteName;
offlineMode = true;
var offName = CFG.offlineName || siteName;
var offArt = CFG.offlineArt || CFG.fallbackArt;
if (el.name) el.name.textContent = offName;
if (el.artist) el.artist.textContent = CFG.offlineMessage || '';
if (el.album) el.album.textContent = '';
if (el.title) el.title.textContent = offName;
if (el.art) { el.art.setAttribute('src', offArt); el.art.title = ''; }
resetTint(); // neutral look while off air
if (CFG.offlineStreamUrl) setSource(CFG.offlineStreamUrl, CFG.offlineLoop);
updateMediaSession({ title: offName, artist: '', album: '' }, offArt);
}
function poll() {
api('api/nowplaying.php')
.then(function (data) {
updateListeners(data);
if (el.liveBadge) el.liveBadge.hidden = !data.online; // green dot = truly broadcasting
if (!data.online) {
if (currentKey !== null) { currentKey = null; goOffline(); }
currentKey = null;
if (!offlineMode) goOffline(); // apply the offline state once
return;
}
if (offlineMode) { // stream came back — return to live
offlineMode = false;
setSource(liveSourceUrl, false);
}
var key = trackKey(data);
if (key !== currentKey && (data.artist || data.title)) {
currentKey = key;
+17
View File
@@ -127,6 +127,18 @@ if (!defined('RADIO_CONFIG_LOADED')) {
'url' => radio_env('STREAM_URL', ''), // <audio> source
'type' => radio_env('STREAM_TYPE', 'audio/mpeg'),
],
// ---- Offline fallback -----------------------------------------
// Shown/played when the now-playing provider reports the stream is
// offline. The fallback audio loops like a "be right back" bed.
'offline' => [
'stream_url' => radio_env('OFFLINE_STREAM_URL', ''), // e.g. assets/audio/offline.mp3
'stream_type' => radio_env('OFFLINE_STREAM_TYPE', 'audio/mpeg'),
'art' => radio_env('OFFLINE_ART', 'assets/img/offline.svg'),
'name' => radio_env('OFFLINE_NAME', 'Off air'),
'message' => radio_env('OFFLINE_MESSAGE', ''),
'loop' => radio_env_bool('OFFLINE_LOOP', true),
],
'nowplaying' => [
// provider: icecast | azuracast | shoutcast | custom
'provider' => strtolower(radio_env('NOWPLAYING_PROVIDER', 'icecast')),
@@ -281,5 +293,10 @@ function radio_client_config() {
'mediaSession' => cfg('player.mediasession'),
'siteName' => cfg('site.name'),
'tagline' => cfg('site.tagline'),
'offlineStreamUrl' => cfg('offline.stream_url'),
'offlineArt' => cfg('offline.art'),
'offlineName' => cfg('offline.name'),
'offlineMessage' => cfg('offline.message'),
'offlineLoop' => cfg('offline.loop'),
];
}
+1 -1
View File
@@ -46,7 +46,7 @@ $hasStream = cfg('stream.url') !== '';
<span class="time" id="current-time">00:00</span>
<input type="range" id="seek" class="range seek" min="0" max="100" value="0" step="0.1" aria-label="Seek" disabled/>
<span class="time" id="total-duration">00:00</span>
<span class="live-badge" id="live-badge" hidden>&#9679; LIVE</span>
<span class="live-badge" id="live-badge" hidden><span class="live-dot"></span><span class="live-text">LIVE</span></span>
</div>
<div class="vol-row">
<button id="mute" class="icon-btn" type="button" aria-label="Mute"></button>