Files
radio/assets/js/player.js
T

893 lines
35 KiB
JavaScript
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.
/*
* 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'),
lyricsLines: $('lyrics-lines'),
lyricsSync: $('lyrics-sync'),
lyricsOffset: $('lyrics-offset'),
historyToggle:$('history-toggle'),
historyBody: $('history-body'),
history: $('history'),
listeners: $('listeners'),
canvas: $('tint-canvas'),
waveform: $('waveform'),
artRing: $('art-ring'),
wavePicker: $('waveform-picker')
};
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 currentTrack = null;
var siteName = CFG.siteName || 'Radio';
/* ------------------------------------------------------- karaoke lyrics */
var trackStartedAt = null; // Date.now() ms estimate of when the track began
var syncedLines = []; // [{time, el}] for the current track, time-sorted
var activeLine = -1;
var karaokeTimer = null;
var lyricsOffsetMs = Number(localStorage.getItem('radio.lyricsOffsetMs')) || 0;
/* ---------------------------------------------------------------- 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).
// Several render styles share one analyser graph; the strip styles
// (bars/line/vu/spectrum/spectrum-mirror/blob) are mutually exclusive
// (one canvas rectangle), while circular/glow are independent overlays —
// circular rings the play/pause button, glow lights up the album art —
// that can combine with a strip style and with each other.
var waveform = (function () {
var wcfg = CFG.waveform || {};
var canvas = el.waveform;
var ring = el.artRing;
if (!wcfg.active || !canvas || !audio) return { start: function () {}, stop: function () {}, isGlowOn: function () { return false; } };
var g = canvas.getContext('2d');
var ringG = ring ? ring.getContext('2d') : null;
var audioCtx = null, analyser = null, source = null, timeData = null, freqData = 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);
// Safari/WebKit (all iOS browsers) never delivers real analyser samples
// for a *live* stream — getFloatTimeDomainData reads back hard zeros
// forever, even though playback itself works fine. Wait a couple of
// seconds for a genuine non-zero sample before ever showing the canvas;
// if none arrives, treat it as unsupported and give up for the session
// instead of displaying a permanently flat/broken-looking line.
var startTs = null, sawSignal = false;
var SILENCE_GRACE_MS = 2000;
var color = wcfg.color || getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e21d31';
var glowLevel = 0;
var STRIP_TYPES = ['bars', 'line', 'vu', 'spectrum', 'spectrum-mirror', 'blob'];
var types = wcfg.types || ['bars'];
var defaults = wcfg.default || [];
function initStrip() {
var saved = localStorage.getItem('radio.waveformStrip');
if (saved !== null && (saved === 'none' || types.indexOf(saved) !== -1)) return saved;
var d = defaults.filter(function (t) { return STRIP_TYPES.indexOf(t) !== -1; })[0];
return d || 'none';
}
function initToggle(key, type) {
var saved = localStorage.getItem('radio.waveform' + key);
if (saved !== null) return saved === '1';
return defaults.indexOf(type) !== -1;
}
var stripType = initStrip();
var circularOn = !!ring && types.indexOf('circular') !== -1 && initToggle('Circular', 'circular');
var glowOn = types.indexOf('glow') !== -1 && initToggle('Glow', 'glow');
// Groups the analyser's frequency bins into `numBuckets` log-scaled
// bands (0..1 each) — fine resolution in the bass, coarse in the
// treble, the standard shape for a spectrum visualizer.
function bucketSpectrum(numBuckets) {
analyser.getByteFrequencyData(freqData);
var bins = freqData.length, out = new Float32Array(numBuckets);
for (var b = 0; b < numBuckets; b++) {
var lo = Math.floor(Math.pow(bins, b / numBuckets));
var hi = Math.max(lo + 1, Math.floor(Math.pow(bins, (b + 1) / numBuckets)));
var sum = 0, n = 0;
for (var i = lo; i < hi && i < bins; i++) { sum += freqData[i]; n++; }
out[b] = n ? (sum / n) / 255 : 0;
}
return out;
}
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);
}
// Measures the parent (.playpause-wrap), not the ring canvas itself —
// the ring is what's toggling hidden/visible, so its own clientWidth/
// Height is an unreliable snapshot right at that transition; the
// parent's layout never depends on the ring's own visibility.
function sizeRing() {
if (!ring) return null;
var box = ring.parentNode ? ring.parentNode.getBoundingClientRect() : null;
var cssW = (box && box.width) || 300;
var cssH = (box && box.height) || cssW;
var w = Math.round(cssW * dpr), h = Math.round(cssH * dpr);
if (ring.width !== w || ring.height !== h) {
ring.width = w;
ring.height = h;
ringG.setTransform(dpr, 0, 0, dpr, 0, 0);
}
return { w: cssW, h: cssH };
}
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);
freqData = new Uint8Array(analyser.frequencyBinCount);
} catch (e) {
graphFailed = true; audioCtx = null; // cross-origin blocked, etc.
return false;
}
return true;
}
function fail() {
graphFailed = true;
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
canvas.hidden = true;
if (ring) ring.hidden = true;
}
/* ---- strip renderers (mutually exclusive, share the canvas rect) ---- */
function drawBars(cssW, cssH) {
var step = BAR + GAP, mid = cssH / 2;
for (var 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 drawBlob(cssW, cssH) {
if (!peaks.length) return;
var step = BAR + GAP, mid = cssH / 2, pts = [], i;
for (i = 0; i < peaks.length; i++) {
var x = cssW - (peaks.length - i) * step;
var h = Math.max(2, peaks[i] * cssH * 0.95);
pts.push([x, mid - h / 2]);
}
g.beginPath();
g.moveTo(pts[0][0], mid);
g.lineTo(pts[0][0], pts[0][1]);
for (i = 1; i < pts.length; i++) {
var prev = pts[i - 1], cur = pts[i];
g.quadraticCurveTo(prev[0], prev[1], (prev[0] + cur[0]) / 2, (prev[1] + cur[1]) / 2);
}
g.lineTo(pts[pts.length - 1][0], pts[pts.length - 1][1]);
for (i = pts.length - 1; i >= 0; i--) g.lineTo(pts[i][0], mid + (mid - pts[i][1]));
g.closePath();
g.fill();
}
function drawLine(cssW, cssH) {
var n = timeData.length, mid = cssH / 2;
g.beginPath();
for (var i = 0; i < n; i++) {
var x = (i / (n - 1)) * cssW;
var y = mid - timeData[i] * mid * 0.95;
if (i === 0) g.moveTo(x, y); else g.lineTo(x, y);
}
g.lineWidth = 2;
g.strokeStyle = color;
g.stroke();
}
function drawVu(cssW, cssH, level) {
g.fillRect(0, 0, Math.max(2, level * cssW), cssH);
}
function drawSpectrum(cssW, cssH, mirror) {
var step = BAR + GAP;
var n = Math.max(1, Math.floor(cssW / step));
var buckets = bucketSpectrum(n);
for (var i = 0; i < n; i++) {
var x = i * step, v = buckets[i];
if (mirror) {
var half = Math.max(1, v * (cssH / 2) * 0.95);
g.fillRect(x, cssH / 2 - half, BAR, half * 2);
} else {
var h = Math.max(2, v * cssH * 0.95);
g.fillRect(x, cssH - h, BAR, h);
}
}
}
/* ---- art overlays (independent, can combine with a strip + each other) */
function drawRing() {
if (!ring || !ringG) return;
if (ring.hidden) ring.hidden = false;
var dims = sizeRing();
if (!dims) return;
var cssW = dims.w, cssH = dims.h;
var cx = cssW / 2, cy = cssH / 2;
// Canvas is deliberately larger than the button (see .playpause-wrap)
// so ticks have room to radiate outward from the button's own edge.
var btn = el.playpause;
var edge = btn ? btn.offsetWidth / 2 : Math.min(cssW, cssH) / 2;
var n = 40;
var buckets = bucketSpectrum(n);
ringG.clearRect(0, 0, cssW, cssH);
ringG.strokeStyle = color;
ringG.lineWidth = 3;
for (var i = 0; i < n; i++) {
var a = (i / n) * Math.PI * 2;
var len = 6 + buckets[i] * 24;
ringG.beginPath();
ringG.moveTo(cx + Math.cos(a) * edge, cy + Math.sin(a) * edge);
ringG.lineTo(cx + Math.cos(a) * (edge + len), cy + Math.sin(a) * (edge + len));
ringG.stroke();
}
}
function applyGlow(level) {
// Typical (non-clipping) audio peaks rarely exceed ~0.3-0.4, which
// read as barely-there on the CSS side — boost before smoothing so
// normal playback volume is actually visible, not just near-clipping peaks.
var target = Math.min(1, level * 2.2);
glowLevel += (target - glowLevel) * 0.15;
el.art.style.setProperty('--glow', glowLevel.toFixed(3));
}
function clearGlow() {
glowLevel = 0;
el.art.style.removeProperty('--glow');
}
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;
if (startTs === null) startTs = ts;
if (m > 0 && !sawSignal) {
sawSignal = true;
if (canvas.hidden) { canvas.hidden = false; size(); }
} else if (!sawSignal && ts - startTs >= SILENCE_GRACE_MS) {
fail();
return;
}
if (!sawSignal) return; // still in the grace window — nothing to draw yet
var cssW = canvas.width / dpr, cssH = canvas.height / dpr;
var maxBars = Math.max(1, Math.floor(cssW / (BAR + GAP)));
if (stripType === 'bars' || stripType === 'blob') {
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;
switch (stripType) {
case 'bars': drawBars(cssW, cssH); break;
case 'blob': drawBlob(cssW, cssH); break;
case 'line': drawLine(cssW, cssH); break;
case 'vu': drawVu(cssW, cssH, m); break;
case 'spectrum': drawSpectrum(cssW, cssH, false); break;
case 'spectrum-mirror': drawSpectrum(cssW, cssH, true); break;
// 'none': nothing drawn, canvas stays cleared
}
if (circularOn) drawRing(); else if (ring && !ring.hidden) ring.hidden = true;
if (glowOn) applyGlow(m); else if (glowLevel) clearGlow();
}
function start() {
if (!ensureGraph()) return;
if (audioCtx.state === 'suspended' && audioCtx.resume) audioCtx.resume();
if (!running) {
running = true; lastCommit = 0; startTs = null; sawSignal = false;
rafId = requestAnimationFrame(draw);
}
}
function stop() {
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
// Picker: sync initial UI to the resolved state (localStorage isn't
// known server-side), then react to changes — no reload needed, the
// running draw() loop picks up new state next frame.
var picker = el.wavePicker;
if (picker) {
var stripInputs = picker.querySelectorAll('input[name="wave-strip"]');
for (var si = 0; si < stripInputs.length; si++) {
stripInputs[si].checked = (stripInputs[si].value === stripType);
}
var circInput = document.getElementById('wave-circular');
if (circInput) circInput.checked = circularOn;
var glowInput = document.getElementById('wave-glow');
if (glowInput) glowInput.checked = glowOn;
picker.addEventListener('change', function (e) {
if (e.target.name === 'wave-strip') {
stripType = e.target.value;
peaks = []; // fresh history instead of showing stale bars/blob data
localStorage.setItem('radio.waveformStrip', stripType);
} else if (e.target.id === 'wave-circular') {
circularOn = e.target.checked;
localStorage.setItem('radio.waveformCircular', circularOn ? '1' : '0');
} else if (e.target.id === 'wave-glow') {
glowOn = e.target.checked;
if (glowOn) {
if (el.art && el.art.src) applyTint(el.art.src); // refresh --text for the new state
} else {
clearGlow();
if (!CFG.dynamicBg) document.documentElement.style.removeProperty('--text'); // glow was the only reason it was set
}
localStorage.setItem('radio.waveformGlow', glowOn ? '1' : '0');
}
});
}
window.addEventListener('resize', function () {
if (!canvas.hidden) size();
if (ring && !ring.hidden) sizeRing();
});
return { start: start, stop: stop, isGlowOn: function () { return glowOn; } };
})();
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) {
// Full-page tint needs THEME_DYNAMIC_BG; but the glow style washes the
// album art in --accent regardless of that setting, so on a dark art
// it still needs the same computed-background -> white-text swap even
// when the page background itself isn't being tinted.
var wantBg = !!CFG.dynamicBg;
var wantText = wantBg || waveform.isGlowOn();
if (!wantText || !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);
if (wantBg) {
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, album);
}
// A provider can return a URL that 404s (Last.fm in particular). Fall back
// to the placeholder once, instead of leaving a broken image on screen.
if (el.art) {
el.art.addEventListener('error', function () {
if (el.art.getAttribute('src') === CFG.fallbackArt) return;
setArt('', '', currentTrack);
});
}
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 tickKaraoke() {
if (!syncedLines.length || trackStartedAt === null) return;
var elapsed = (Date.now() - trackStartedAt + lyricsOffsetMs) / 1000;
var idx = -1;
for (var i = 0; i < syncedLines.length; i++) {
if (syncedLines[i].time <= elapsed) idx = i; else break;
}
if (idx === activeLine) return;
if (activeLine >= 0) syncedLines[activeLine].el.classList.remove('current');
activeLine = idx;
if (idx >= 0) {
syncedLines[idx].el.classList.add('current');
syncedLines[idx].el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}
function startKaraoke() {
stopKaraoke();
activeLine = -1;
tickKaraoke();
karaokeTimer = setInterval(tickKaraoke, 300);
}
function stopKaraoke() {
if (karaokeTimer) clearInterval(karaokeTimer);
karaokeTimer = null;
}
function updateOffsetLabel() {
if (el.lyricsOffset) el.lyricsOffset.textContent = (lyricsOffsetMs / 1000).toFixed(1) + 's';
}
updateOffsetLabel();
if (el.lyricsSync) {
el.lyricsSync.addEventListener('click', function (e) {
var adj = e.target.getAttribute('data-adj');
if (!adj) return;
lyricsOffsetMs += Number(adj) * 1000;
localStorage.setItem('radio.lyricsOffsetMs', lyricsOffsetMs);
updateOffsetLabel();
});
}
// Click a line to say "this is playing right now" — re-anchors the karaoke
// clock to that line, which is how we recover from loading mid-song (we
// have no reliable elapsed time on most providers) or from drift building
// up over a long track. The buffering-delay nudge above is left as-is.
if (el.lyricsLines) {
el.lyricsLines.addEventListener('click', function (e) {
var btn = e.target.closest('.lyrics-line');
if (!btn) return;
var line = syncedLines.filter(function (l) { return l.el === btn; })[0];
if (!line) return;
trackStartedAt = Date.now() + lyricsOffsetMs - line.time * 1000;
startKaraoke();
});
}
function loadLyrics(track) {
if (!CFG.lyricsEnabled || !el.lyricsToggle) return;
el.lyricsToggle.hidden = true;
el.lyricsToggle.setAttribute('aria-pressed', 'false');
stopKaraoke();
syncedLines = [];
if (el.lyrics) el.lyrics.hidden = true;
if (el.lyricsLines) el.lyricsLines.textContent = '';
if (el.lyricsSync) el.lyricsSync.hidden = true;
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 || !el.lyricsLines) return;
if (res.synced && res.synced.length) {
syncedLines = res.synced.map(function (l) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'lyrics-line';
btn.textContent = l.text;
el.lyricsLines.appendChild(btn);
return { time: l.time, el: btn };
});
var panelOpen = el.lyrics && !el.lyrics.hidden;
if (el.lyricsSync) el.lyricsSync.hidden = !panelOpen;
if (panelOpen) startKaraoke();
} else {
el.lyricsLines.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');
if (show && syncedLines.length) {
startKaraoke();
if (el.lyricsSync) el.lyricsSync.hidden = false;
} else {
stopKaraoke();
if (el.lyricsSync) el.lyricsSync.hidden = true;
}
});
}
/* ------------------------------------------------------------- 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, album) {
if (!CFG.mediaSession || !('mediaSession' in navigator) || !window.MediaMetadata) return;
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title || '', artist: track.artist || '',
album: 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) {
currentTrack = t;
trackStartedAt = Date.now() - (Number(t.elapsed) > 0 ? Number(t.elapsed) * 1000 : 0);
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();
})();