If stream offline have a fallback source if configured
This commit is contained in:
1 parent
bac13da757
commit
e978ab722d
5 files changed
+204
-9
No files matched your search
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
@@ -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'),
|
||||
];
|
||||
}
|
||||
@@ -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>● 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>
|
||||
|
||||
Reference in new issue
Block a user