Files
radio/assets/js/player.js
T
2026-07-27 09:44:22 -05:00

545 lines
21 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'),
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();
})();