Files
radio/assets/js/player.js
T
2026-07-24 12:48:54 -05:00

408 lines
16 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'),
historyToggle:$('history-toggle'),
historyBody: $('history-body'),
history: $('history'),
listeners: $('listeners'),
canvas: $('tint-canvas')
};
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;
});
}
}
/* --------------------------------------------------- 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 */
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) applyTint(url); else resetTint();
}
function resolveArtwork(track, providerArt) {
var source = CFG.artworkSource || 'auto';
if (source === 'none') { setArt('', ''); return; }
if (source === 'provider') { setArt(providerArt, track.album); return; }
if (source === 'auto' && providerArt) { setArt(providerArt, track.album); return; }
if (!CFG.artworkLookup) { setArt(providerArt || '', track.album); 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);
if (res.album && el.album) el.album.textContent = res.album;
})
.catch(function () { setArt(providerArt || '', track.album); });
}
/* -------------------------------------------------------------- lyrics */
function loadLyrics(track) {
if (!CFG.lyricsEnabled || !el.lyricsToggle) return;
el.lyricsToggle.hidden = true;
el.lyricsToggle.textContent = 'Show lyrics';
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.textContent = show ? 'Hide lyrics' : 'Show lyrics';
});
}
/* ------------------------------------------------------------- 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);
updateMediaSession(t, providerArt || (el.art ? el.art.getAttribute('src') : ''));
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
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();
})();