545 lines
21 KiB
JavaScript
545 lines
21 KiB
JavaScript
/*
|
||
* player.js — front-end for the radio site.
|
||
*
|
||
* Reads window.RADIO_CONFIG (from index.php) and talks only to our own /api
|
||
* endpoints, so it contains no station-specific URLs or strings.
|
||
*
|
||
* - custom audio controls (play/pause, seek, volume, elapsed time, LIVE badge)
|
||
* - polls /api/nowplaying.php for artist / title / album / listeners
|
||
* - resolves artwork (provider art, else /api/artwork.php)
|
||
* - optional: page background tinted to the album art's average color
|
||
* - optional: MediaSession metadata (OS / lock-screen controls)
|
||
* - on-demand lyrics + recently-played history
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
var CFG = window.RADIO_CONFIG || {};
|
||
var POLL_MS = Math.max(3, CFG.pollSeconds || 10) * 1000;
|
||
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
var el = {
|
||
title: $('track-title'),
|
||
name: $('track-name'),
|
||
artist: $('track-artist'),
|
||
album: $('track-album'),
|
||
art: $('art'),
|
||
ogImage: $('metaimage1'),
|
||
card: $('card'),
|
||
audio: $('stream'),
|
||
playpause: $('playpause'),
|
||
seek: $('seek'),
|
||
curTime: $('current-time'),
|
||
totTime: $('total-duration'),
|
||
liveBadge: $('live-badge'),
|
||
mute: $('mute'),
|
||
volume: $('volume'),
|
||
lyricsToggle: $('lyrics-toggle'),
|
||
lyrics: $('lyrics'),
|
||
historyToggle:$('history-toggle'),
|
||
historyBody: $('history-body'),
|
||
history: $('history'),
|
||
listeners: $('listeners'),
|
||
canvas: $('tint-canvas'),
|
||
waveform: $('waveform')
|
||
};
|
||
|
||
var ICON = {
|
||
play: '<svg viewBox="0 0 24 24" width="34" height="34" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>',
|
||
pause: '<svg viewBox="0 0 24 24" width="34" height="34" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>',
|
||
vol: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3z"/></svg>',
|
||
mute: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3l2.5-2.5-1-1L15.5 11 13 8.5l-1 1L14.5 12 12 14.5l1 1 2.5-2.5 2.5 2.5 1-1z"/></svg>'
|
||
};
|
||
|
||
var currentKey = null;
|
||
var siteName = CFG.siteName || 'Radio';
|
||
|
||
/* ---------------------------------------------------------------- utils */
|
||
function trackKey(t) { return (t.artist || '') + '' + (t.title || ''); }
|
||
function fmtClock(secs) {
|
||
if (!isFinite(secs) || secs < 0) secs = 0;
|
||
var m = Math.floor(secs / 60), s = Math.floor(secs % 60);
|
||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||
}
|
||
function fmtTimeOfDay(d) {
|
||
function z(n) { return (n < 10 ? '0' : '') + n; }
|
||
return z(d.getHours()) + ':' + z(d.getMinutes()) + ':' + z(d.getSeconds());
|
||
}
|
||
function api(path, opts) {
|
||
return fetch(path, opts).then(function (r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.json();
|
||
});
|
||
}
|
||
|
||
/* -------------------------------------------------------- audio controls */
|
||
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;
|
||
|
||
// iOS Safari: play() must be called directly inside the tap. Calling
|
||
// audio.load() first drops the user-gesture flag and iOS rejects playback,
|
||
// so play() directly, then fall back to an explicit src + load + retry
|
||
// (still within Safari's transient activation window) if it's rejected.
|
||
function startPlayback() {
|
||
var p;
|
||
try { p = audio.play(); } catch (e) { p = null; }
|
||
if (p && typeof p.catch === 'function') {
|
||
p.catch(function () {
|
||
try {
|
||
if (!audio.getAttribute('src') && liveSourceUrl) audio.src = liveSourceUrl;
|
||
audio.load();
|
||
var p2 = audio.play();
|
||
if (p2 && p2.catch) p2.catch(function (err) {
|
||
console.warn('Radio: playback was blocked —', err && err.name);
|
||
});
|
||
} catch (e2) {}
|
||
});
|
||
}
|
||
}
|
||
|
||
el.playpause.addEventListener('click', function () {
|
||
if (audio.paused) startPlayback();
|
||
else audio.pause();
|
||
});
|
||
audio.addEventListener('play', function () {
|
||
el.playpause.innerHTML = ICON.pause;
|
||
el.playpause.setAttribute('aria-label', 'Pause');
|
||
});
|
||
audio.addEventListener('pause', function () {
|
||
el.playpause.innerHTML = ICON.play;
|
||
el.playpause.setAttribute('aria-label', 'Play');
|
||
});
|
||
|
||
// Volume
|
||
if (el.volume) {
|
||
audio.volume = 1;
|
||
el.volume.addEventListener('input', function () {
|
||
audio.volume = el.volume.value / 100;
|
||
audio.muted = (audio.volume === 0);
|
||
});
|
||
}
|
||
if (el.mute) {
|
||
el.mute.innerHTML = ICON.vol;
|
||
el.mute.addEventListener('click', function () {
|
||
audio.muted = !audio.muted;
|
||
el.mute.innerHTML = audio.muted ? ICON.mute : ICON.vol;
|
||
});
|
||
}
|
||
|
||
// 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.seek) el.seek.disabled = live || isNaN(audio.duration);
|
||
if (el.totTime) el.totTime.style.visibility = live ? 'hidden' : 'visible';
|
||
}
|
||
audio.addEventListener('loadedmetadata', reflectDuration);
|
||
audio.addEventListener('durationchange', reflectDuration);
|
||
|
||
audio.addEventListener('timeupdate', function () {
|
||
if (el.curTime) el.curTime.textContent = fmtClock(audio.currentTime);
|
||
if (!isLive() && isFinite(audio.duration)) {
|
||
if (el.totTime) el.totTime.textContent = fmtClock(audio.duration);
|
||
if (el.seek && !seeking) el.seek.value = (audio.currentTime / audio.duration) * 100 || 0;
|
||
}
|
||
});
|
||
|
||
var seeking = false;
|
||
if (el.seek) {
|
||
el.seek.addEventListener('input', function () { seeking = true; });
|
||
el.seek.addEventListener('change', function () {
|
||
if (!isLive() && isFinite(audio.duration)) {
|
||
audio.currentTime = audio.duration * (el.seek.value / 100);
|
||
}
|
||
seeking = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------ waveform viz */
|
||
// Real-time scrolling "building" waveform via Web Audio. Only runs when the
|
||
// server said it's safe (same-origin stream, or cross-origin + CORS), and
|
||
// only after playback starts (AudioContext needs a user gesture anyway).
|
||
var waveform = (function () {
|
||
var wcfg = CFG.waveform || {};
|
||
var canvas = el.waveform;
|
||
if (!wcfg.active || !canvas || !audio) return { start: function () {}, stop: function () {} };
|
||
|
||
var g = canvas.getContext('2d');
|
||
var audioCtx = null, analyser = null, source = null, timeData = null, graphFailed = false;
|
||
var peaks = [], runningMax = 0, lastCommit = 0, rafId = null, running = false;
|
||
var dpr = window.devicePixelRatio || 1;
|
||
var BAR = 3, GAP = 1;
|
||
var winSec = Math.max(4, wcfg.window || 30);
|
||
var color = wcfg.color || getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e21d31';
|
||
|
||
function size() {
|
||
var cssW = canvas.clientWidth || (canvas.parentNode ? canvas.parentNode.clientWidth : 300) || 300;
|
||
var cssH = wcfg.height || 72;
|
||
canvas.width = Math.round(cssW * dpr);
|
||
canvas.height = Math.round(cssH * dpr);
|
||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
}
|
||
|
||
function ensureGraph() {
|
||
if (audioCtx) return true;
|
||
if (graphFailed) return false;
|
||
var AC = window.AudioContext || window.webkitAudioContext;
|
||
if (!AC) { graphFailed = true; return false; }
|
||
try {
|
||
audioCtx = new AC();
|
||
source = audioCtx.createMediaElementSource(audio); // once per element
|
||
analyser = audioCtx.createAnalyser();
|
||
analyser.fftSize = 1024;
|
||
source.connect(analyser);
|
||
analyser.connect(audioCtx.destination); // keep audio audible
|
||
timeData = new Float32Array(analyser.fftSize);
|
||
} catch (e) {
|
||
graphFailed = true; audioCtx = null; // cross-origin blocked, etc.
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function draw(ts) {
|
||
if (!running) return;
|
||
rafId = requestAnimationFrame(draw);
|
||
if (!analyser) return;
|
||
analyser.getFloatTimeDomainData(timeData);
|
||
var m = 0, i;
|
||
for (i = 0; i < timeData.length; i++) { var v = Math.abs(timeData[i]); if (v > m) m = v; }
|
||
if (m > runningMax) runningMax = m;
|
||
|
||
var cssW = canvas.width / dpr, cssH = canvas.height / dpr;
|
||
var step = BAR + GAP;
|
||
var maxBars = Math.max(1, Math.floor(cssW / step));
|
||
var interval = (winSec * 1000) / maxBars;
|
||
if (!lastCommit) lastCommit = ts;
|
||
if (ts - lastCommit >= interval) {
|
||
lastCommit = ts;
|
||
peaks.push(runningMax);
|
||
runningMax = 0;
|
||
while (peaks.length > maxBars) peaks.shift();
|
||
}
|
||
|
||
g.clearRect(0, 0, cssW, cssH);
|
||
g.fillStyle = color;
|
||
var mid = cssH / 2;
|
||
for (i = 0; i < peaks.length; i++) {
|
||
var x = cssW - (peaks.length - i) * step;
|
||
var h = Math.max(2, peaks[i] * cssH * 0.95);
|
||
g.fillRect(x, mid - h / 2, BAR, h);
|
||
}
|
||
}
|
||
|
||
function start() {
|
||
if (!ensureGraph()) return;
|
||
if (audioCtx.state === 'suspended' && audioCtx.resume) audioCtx.resume();
|
||
if (canvas.hidden) { canvas.hidden = false; size(); }
|
||
if (!running) { running = true; lastCommit = 0; rafId = requestAnimationFrame(draw); }
|
||
}
|
||
function stop() {
|
||
running = false;
|
||
if (rafId) cancelAnimationFrame(rafId);
|
||
rafId = null;
|
||
}
|
||
|
||
window.addEventListener('resize', function () { if (!canvas.hidden) size(); });
|
||
return { start: start, stop: stop };
|
||
})();
|
||
|
||
if (audio) {
|
||
audio.addEventListener('play', function () { waveform.start(); });
|
||
audio.addEventListener('pause', function () { waveform.stop(); });
|
||
}
|
||
|
||
/* --------------------------------------------------- background tinting */
|
||
function luminance(r, g, b) { return 0.299 * r + 0.587 * g + 0.114 * b; }
|
||
|
||
function resetTint() {
|
||
document.body.style.backgroundColor = '';
|
||
if (el.card) el.card.style.backgroundColor = '';
|
||
document.documentElement.style.removeProperty('--text');
|
||
}
|
||
|
||
function applyTint(url) {
|
||
if (!CFG.dynamicBg || !el.canvas || !url) return;
|
||
// Sample with a separate CORS image so the *visible* art still shows even
|
||
// if the host blocks cross-origin canvas reads.
|
||
var img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = function () {
|
||
try {
|
||
var w = 40, h = 40;
|
||
el.canvas.width = w; el.canvas.height = h;
|
||
var ctx = el.canvas.getContext('2d');
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
var data = ctx.getImageData(0, 0, w, h).data;
|
||
var r = 0, g = 0, b = 0, n = 0;
|
||
for (var i = 0; i < data.length; i += 4 * 5) { r += data[i]; g += data[i+1]; b += data[i+2]; n++; }
|
||
r = Math.round(r / n); g = Math.round(g / n); b = Math.round(b / n);
|
||
var rgb = 'rgb(' + r + ',' + g + ',' + b + ')';
|
||
document.body.style.backgroundColor = rgb;
|
||
if (el.card) el.card.style.backgroundColor = rgb;
|
||
document.documentElement.style.setProperty('--text', luminance(r, g, b) > 140 ? '#111' : '#fff');
|
||
} catch (e) { /* cross-origin tainted canvas — skip tint */ }
|
||
};
|
||
img.onerror = function () { /* CORS/load failed — keep default theme */ };
|
||
img.src = url;
|
||
}
|
||
|
||
/* ------------------------------------------------------------- artwork */
|
||
// Point the OpenGraph image + browser tab favicon at the current album art.
|
||
// The static set is removed (including the SVG favicon, which Firefox would
|
||
// otherwise prefer) and replaced with a single album-art icon; the originals
|
||
// are kept so resetMeta() can restore them when the stream goes offline.
|
||
// apple-touch-icon and the web manifest are left alone (home-screen / PWA).
|
||
var ICON_SEL = 'link[rel~="icon"], link[rel="shortcut icon"]';
|
||
var _origIcons = null;
|
||
var _origOg = null;
|
||
|
||
function updateMeta(url) {
|
||
if (!CFG.dynamicMeta || !url) return;
|
||
if (_origOg === null && el.ogImage) _origOg = el.ogImage.getAttribute('content') || '';
|
||
if (el.ogImage) el.ogImage.setAttribute('content', url);
|
||
|
||
var head = document.head, existing = head.querySelectorAll(ICON_SEL), i;
|
||
if (_origIcons === null) {
|
||
_origIcons = [];
|
||
for (i = 0; i < existing.length; i++) _origIcons.push(existing[i]);
|
||
}
|
||
for (i = 0; i < existing.length; i++) {
|
||
if (existing[i].parentNode) existing[i].parentNode.removeChild(existing[i]);
|
||
}
|
||
var link = document.createElement('link');
|
||
link.id = 'dyn-favicon';
|
||
link.rel = 'icon';
|
||
link.href = url;
|
||
head.appendChild(link);
|
||
}
|
||
|
||
// Put the station's own favicon + OG image back (e.g. when off air).
|
||
function resetMeta() {
|
||
if (_origIcons === null) return;
|
||
var head = document.head, cur = head.querySelectorAll(ICON_SEL), i;
|
||
for (i = 0; i < cur.length; i++) if (cur[i].parentNode) cur[i].parentNode.removeChild(cur[i]);
|
||
for (i = 0; i < _origIcons.length; i++) head.appendChild(_origIcons[i]);
|
||
if (el.ogImage && _origOg !== null) el.ogImage.setAttribute('content', _origOg);
|
||
}
|
||
|
||
function setArt(url, album, track) {
|
||
var src = url || CFG.fallbackArt;
|
||
if (el.art) {
|
||
el.art.setAttribute('src', src);
|
||
if (album) el.art.title = 'From "' + album + '"';
|
||
}
|
||
if (url) updateMeta(url);
|
||
if (url) applyTint(url); else resetTint();
|
||
if (track) updateMediaSession(track, src);
|
||
}
|
||
|
||
function resolveArtwork(track, providerArt) {
|
||
var source = CFG.artworkSource || 'auto';
|
||
if (source === 'none') { setArt('', '', track); return; }
|
||
if (source === 'provider') { setArt(providerArt, track.album, track); return; }
|
||
if (source === 'auto' && providerArt) { setArt(providerArt, track.album, track); return; }
|
||
if (!CFG.artworkLookup) { setArt(providerArt || '', track.album, track); return; }
|
||
|
||
var body = new URLSearchParams();
|
||
body.set('artist', track.artist || '');
|
||
body.set('title', track.title || '');
|
||
api('api/artwork.php', { method: 'POST', body: body })
|
||
.then(function (res) {
|
||
setArt(res.art || providerArt || '', res.album || track.album, track);
|
||
if (res.album && el.album) el.album.textContent = res.album;
|
||
})
|
||
.catch(function () { setArt(providerArt || '', track.album, track); });
|
||
}
|
||
|
||
/* -------------------------------------------------------------- lyrics */
|
||
function loadLyrics(track) {
|
||
if (!CFG.lyricsEnabled || !el.lyricsToggle) return;
|
||
el.lyricsToggle.hidden = true;
|
||
el.lyricsToggle.setAttribute('aria-pressed', 'false');
|
||
if (el.lyrics) { el.lyrics.hidden = true; el.lyrics.textContent = ''; }
|
||
|
||
var body = new URLSearchParams();
|
||
body.set('artist', track.artist || '');
|
||
body.set('title', track.title || '');
|
||
api('api/lyrics.php', { method: 'POST', body: body })
|
||
.then(function (res) {
|
||
if (res.found && res.lyrics) {
|
||
if (el.lyrics) el.lyrics.textContent = res.lyrics;
|
||
el.lyricsToggle.hidden = false;
|
||
}
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
|
||
if (el.lyricsToggle) {
|
||
el.lyricsToggle.addEventListener('click', function () {
|
||
if (!el.lyrics) return;
|
||
var show = el.lyrics.hidden;
|
||
el.lyrics.hidden = !show;
|
||
el.lyricsToggle.setAttribute('aria-pressed', show ? 'true' : 'false');
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------- history */
|
||
function addHistory(track) {
|
||
if (!el.historyBody) return;
|
||
var tr = document.createElement('tr');
|
||
[fmtTimeOfDay(new Date()), track.artist || '', track.title || ''].forEach(function (v) {
|
||
var td = document.createElement('td');
|
||
td.textContent = v;
|
||
tr.appendChild(td);
|
||
});
|
||
el.historyBody.insertBefore(tr, el.historyBody.firstChild);
|
||
while (el.historyBody.children.length > 100) el.historyBody.removeChild(el.historyBody.lastChild);
|
||
}
|
||
|
||
if (el.historyToggle && el.history) {
|
||
el.historyToggle.addEventListener('click', function () {
|
||
var open = el.history.classList.toggle('open');
|
||
el.historyToggle.classList.toggle('open', open);
|
||
el.historyToggle.textContent = open ? 'Close' : 'Song list';
|
||
});
|
||
}
|
||
|
||
/* ----------------------------------------------------------- listeners */
|
||
function updateListeners(data) {
|
||
if (!el.listeners) return;
|
||
if (data.listeners == null) { el.listeners.textContent = ''; return; }
|
||
var text = String(data.listeners);
|
||
if (data.listeners_peak != null) text += ' / ' + data.listeners_peak;
|
||
if (CFG.listenersLink) {
|
||
var a = document.createElement('a');
|
||
a.href = CFG.listenersLink; a.target = '_blank'; a.rel = 'noopener';
|
||
a.textContent = text;
|
||
el.listeners.textContent = '';
|
||
el.listeners.appendChild(a);
|
||
} else {
|
||
el.listeners.textContent = text;
|
||
}
|
||
}
|
||
|
||
/* --------------------------------------------------------- MediaSession */
|
||
function setupMediaSession() {
|
||
if (!CFG.mediaSession || !('mediaSession' in navigator) || !audio) return;
|
||
try {
|
||
navigator.mediaSession.setActionHandler('play', function () { audio.play().catch(function(){}); });
|
||
navigator.mediaSession.setActionHandler('pause', function () { audio.pause(); });
|
||
navigator.mediaSession.setActionHandler('stop', function () { audio.pause(); });
|
||
} catch (e) {}
|
||
}
|
||
function updateMediaSession(track, art) {
|
||
if (!CFG.mediaSession || !('mediaSession' in navigator) || !window.MediaMetadata) return;
|
||
try {
|
||
navigator.mediaSession.metadata = new MediaMetadata({
|
||
title: track.title || '', artist: track.artist || '',
|
||
album: track.album || siteName,
|
||
artwork: art ? [{ src: art }] : []
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
/* --------------------------------------------------------- analytics */
|
||
function trackEvent(artist, title) {
|
||
if (window._paq && artist && title) window._paq.push(['trackEvent', 'Song', artist, title]);
|
||
}
|
||
|
||
/* --------------------------------------------------------------- poll */
|
||
function onNewTrack(t, providerArt) {
|
||
var joined = (t.artist && t.title) ? (t.artist + ' – ' + t.title) : (t.title || t.artist || '');
|
||
if (el.name) el.name.textContent = t.title || siteName;
|
||
if (el.artist) el.artist.textContent = t.artist || '';
|
||
if (el.album) el.album.textContent = t.album || '';
|
||
if (el.title) el.title.textContent = joined ? (joined + ' | ' + siteName) : siteName;
|
||
addHistory(t);
|
||
resolveArtwork(t, providerArt);
|
||
loadLyrics(t);
|
||
trackEvent(t.artist, t.title);
|
||
}
|
||
|
||
function goOffline() {
|
||
offlineMode = true;
|
||
var offName = CFG.offlineName || 'Off air';
|
||
var offArt = CFG.offlineArt || 'assets/img/offline.svg';
|
||
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
|
||
resetMeta(); // station favicon + OG back
|
||
if (CFG.offlineStreamUrl) setSource(CFG.offlineStreamUrl, CFG.offlineLoop);
|
||
updateMediaSession({ title: offName, artist: '', album: '' }, offArt);
|
||
}
|
||
|
||
// Test switch: append ?offline=1 to the URL to force the offline state so you
|
||
// can preview the offline art + fallback audio without stopping the stream.
|
||
var FORCE_OFFLINE = (function () {
|
||
try { return new URLSearchParams(location.search).get('offline') === '1'; }
|
||
catch (e) { return false; }
|
||
})();
|
||
|
||
function markOffline() {
|
||
if (el.liveBadge) el.liveBadge.hidden = true;
|
||
currentKey = null;
|
||
if (!offlineMode) goOffline(); // apply the offline state once
|
||
}
|
||
|
||
function applyState(data) {
|
||
if (FORCE_OFFLINE || !data) data = { online: false };
|
||
updateListeners(data);
|
||
if (el.liveBadge) el.liveBadge.hidden = !data.online; // green dot = truly broadcasting
|
||
if (!data.online) { markOffline(); 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;
|
||
onNewTrack(data, data.art || '');
|
||
}
|
||
}
|
||
|
||
function poll() {
|
||
api('api/nowplaying.php')
|
||
.then(applyState)
|
||
// Endpoint unreachable or returned invalid JSON (e.g. a PHP warning before
|
||
// the JSON): treat as offline so the fallback engages instead of freezing
|
||
// on the initial online placeholder.
|
||
.catch(function () { markOffline(); })
|
||
.then(function () { setTimeout(poll, POLL_MS); });
|
||
}
|
||
|
||
setupMediaSession();
|
||
poll();
|
||
})();
|