Files
rangerhq-radio/assets/js/radio.js
T
ranger 2bd501d610 feat(0.5.0): track history + favourites with four search providers
Quietly log every track that scrolls past the player to a per-user
history (capped 500). A star button promotes the good ones to a
separate Favourites tab that doesn't age out. Each row has four
search-provider deep links so you can find the track on whichever
service you use.

Storage
  Two new user_meta keys (separate from radio_state so frequent
  inserts don't churn the player-state blob):
    - radio_history    — capped FIFO of last 500 played tracks
    - radio_favourites — uncapped user-starred list

New page: Radio → History
  - History tab: capped FIFO list, newest first.
  - Favourites tab: starred tracks (uncapped).
  - Each row: when (relative + full timestamp on hover) / station /
    artist — title / four search links / favourite-star toggle.
  - Filter by artist/title (live, client-side) + by station (dropdown).
  - Clear history button (favourites preserved).
  - Search providers: Spotify, YouTube, Apple Music, Bandcamp.
    Deep-link URLs only — no API keys, no third-party JS.

Auto-logging during playback
  fetchTrack (existing 30s SomaFM poll loop) now hands new tracks to
  logTrackIfNew → POST to wp_ajax_radio_log_track. Dedup client-side
  (lastLoggedSig) AND server-side (against last entry in user_meta).
  Junk filtered server-side: (unknown) artist, missing artist/title.

New AJAX endpoints
  - radio_log_track          (nonce radio_save_state — player pages)
  - radio_toggle_favourite   (nonce radio_history — history page only)
  - radio_clear_history      (nonce radio_history — history page only)

Files
  - radio.php (version, require new include, submenu page, asset
    enqueue hook adds radio_page_radio-history, three AJAX endpoints,
    three new localized strings)
  - inc/history.php (NEW — storage helpers + admin page renderer)
  - assets/css/radio.css (history table, toolbar, search-link pills,
    favourite star, dark-theme overrides)
  - assets/js/radio.js (logTrackIfNew wired into fetchTrack;
    bindHistoryPage handles filter/favourite/clear)
  - inc/about.php (history entry)
  - CHANGELOG.md (full entry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:55:18 +01:00

544 lines
24 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.
/**
* Radio — vanilla JS audio controller.
*
* Binds to every `.radio-player` element on the page (dashboard widget
* + main page can co-exist; both share one logical audio session per
* surface). Persists station + volume changes via AJAX so they survive
* page reloads.
*
* v0.3.0 additions:
* - Mute toggle on the speaker icon (preserves prior volume).
* - MediaSession API integration — OS media keys (F8 on Mac, headphone
* buttons, lock-screen widget) control the player.
* - SomaFM current-track polling (every 30s while playing). Best-effort:
* silently hidden if the API is unavailable / CORS-blocked.
* - Save errors surface to the user as a transient notice rather than
* being swallowed silently.
* - Settings-page volume slider live-label is wired here (no inline
* `oninput` in settings.php).
*
* No build step. No dependencies.
*/
(function () {
'use strict';
if (typeof window.RadioPlugin === 'undefined') { return; }
var cfg = window.RadioPlugin;
var stations = Array.isArray(cfg.stations) ? cfg.stations : [];
/** Look up a station by id. */
function findStation(stationId) {
for (var i = 0; i < stations.length; i++) {
if (stations[i].id === stationId) { return stations[i]; }
}
return null;
}
/** Extract the SomaFM short-code from a stream URL.
* e.g. https://ice1.somafm.com/groovesalad-128-mp3 → "groovesalad".
* Used for the current-track JSON endpoint. */
function getSomaCode(station) {
if (!station || !station.url) { return null; }
var m = station.url.match(/somafm\.com\/([^/]+?)-\d+-mp3/);
return m ? m[1] : null;
}
/** Persist a state patch back to the server. On failure, surface a
* transient notice on the originating player surface (if given) so
* the user knows their preference didn't save. */
function saveState(patch, surface) {
if (!cfg.ajaxUrl || !cfg.nonce) { return; }
var fd = new FormData();
fd.append('action', 'radio_save_state');
fd.append('nonce', cfg.nonce);
Object.keys(patch).forEach(function (k) { fd.append(k, patch[k]); });
fetch(cfg.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body: fd
}).then(function (r) {
if (!r.ok) { throw new Error('save failed: HTTP ' + r.status); }
}).catch(function (err) {
if (window.console && console.warn) { console.warn('Radio:', err); }
if (surface) {
showError(surface, cfg.strings.saveError || 'Preferences not saved.');
setTimeout(function () { showError(surface, null); }, 3500);
}
});
}
/** Update play/pause button glyph + label to reflect current audio state.
* v0.3.2: switched from .dashicons to a Unicode glyph so the symbol sits
* on the text baseline instead of below it. */
function setPlayIcon(btn, playing) {
var glyph = btn.querySelector('[data-radio-play-glyph]');
var label = btn.querySelector('[data-radio-play-label]');
if (glyph) { glyph.textContent = playing ? '‖' : '▶'; } // ‖ or ▶
if (label) { label.textContent = playing ? (cfg.strings.pause || 'Pause') : (cfg.strings.play || 'Play'); }
btn.setAttribute('title', playing ? (cfg.strings.pause || 'Pause') : (cfg.strings.play || 'Play'));
}
/** Show an error message inside a given .radio-player surface. */
function showError(player, msg) {
var err = player.querySelector('[data-radio-error]');
if (!err) { return; }
if (msg) {
err.textContent = msg;
err.hidden = false;
} else {
err.textContent = '';
err.hidden = true;
}
}
/** Update the MediaSession metadata + handlers so OS media keys work. */
function updateMediaSession(audio, station) {
if (!('mediaSession' in navigator) || !station) { return; }
try {
if (typeof window.MediaMetadata === 'function') {
navigator.mediaSession.metadata = new window.MediaMetadata({
title: station.name,
artist: 'SomaFM',
album: station.genre || ''
});
}
navigator.mediaSession.setActionHandler('play', function () { audio.play(); });
navigator.mediaSession.setActionHandler('pause', function () { audio.pause(); });
} catch (e) { /* ignore — older browsers / locked-down contexts */ }
}
/** SomaFM current-track polling. Best-effort: stays hidden if the API
* is unreachable / CORS-blocked (the plugin keeps working regardless).
* v0.5.0: hands new tracks to logTrackIfNew so they land in user history. */
function startTrackPolling(player, station) {
stopTrackPolling(player);
var trackEl = player.querySelector('[data-radio-track]');
if (!trackEl) { return; }
var code = getSomaCode(station);
if (!code) { trackEl.hidden = true; return; }
var url = 'https://somafm.com/songs/' + code + '.json';
function fetchTrack() {
fetch(url, { credentials: 'omit', cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.songs || !data.songs.length) { trackEl.hidden = true; return; }
var s = data.songs[0];
var title = (s.title || '').trim();
var artist = (s.artist || '').trim();
if (!title && !artist) { trackEl.hidden = true; return; }
trackEl.textContent = artist ? (title + ' — ' + artist) : title;
trackEl.hidden = false;
logTrackIfNew(artist, title, station);
})
.catch(function () { trackEl.hidden = true; });
}
fetchTrack();
player._trackTimer = setInterval(fetchTrack, 30000);
}
/** v0.5.0: POST a newly-played track to the server history.
* Deduped client-side via lastLoggedSig so consecutive polls of the
* same song don't spam the endpoint (server dedups too as belt+braces). */
var lastLoggedSig = '';
function logTrackIfNew(artist, title, station) {
if (!cfg.ajaxUrl || !cfg.nonce) { return; }
if (!artist || !title) { return; }
if (artist.toLowerCase() === '(unknown)') { return; }
var sig = (artist + '|' + title + '|' + ((station && station.id) || '')).toLowerCase();
if (sig === lastLoggedSig) { return; }
lastLoggedSig = sig;
var fd = new FormData();
fd.append('action', 'radio_log_track');
fd.append('nonce', cfg.nonce);
fd.append('artist', artist);
fd.append('title', title);
fd.append('station', (station && station.name) || '');
fd.append('station_id', (station && station.id) || '');
fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: fd })
.catch(function () { /* track log is best-effort */ });
}
function stopTrackPolling(player) {
if (player._trackTimer) {
clearInterval(player._trackTimer);
player._trackTimer = null;
}
var trackEl = player.querySelector('[data-radio-track]');
if (trackEl) { trackEl.hidden = true; trackEl.textContent = ''; }
}
/** Web Audio frequency visualizer — progressive upgrade over the CSS
* dancing bars. Tries once per player surface; if CORS or browser
* support blocks it, silently leaves the CSS bars in place.
*
* State machine on player._vizState:
* undefined → not yet tried
* 'no-webaudio' → browser lacks AudioContext
* 'init-failed' → createMediaElementSource threw (e.g. already wired)
* 'cors-blocked' → analyser returned all-zeros for >2s → CORS silently failed
* 'ok' → live frequency data flowing → canvas visible, bars hidden
*/
function tryVisualizer(player, audio) {
if (player._vizState) { return; }
var AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) { player._vizState = 'no-webaudio'; return; }
var canvas = player.querySelector('[data-radio-viz]');
if (!canvas) { player._vizState = 'no-canvas'; return; }
var ctx, srcNode, analyser;
try {
ctx = new AudioCtx();
srcNode = ctx.createMediaElementSource(audio);
analyser = ctx.createAnalyser();
analyser.fftSize = 64; // → 32 frequency bins, plenty for a 60px-wide canvas
srcNode.connect(analyser);
analyser.connect(ctx.destination); // KEEP audio audible
} catch (e) {
if (window.console && console.warn) { console.warn('Radio viz: init failed —', e.message || e); }
player._vizState = 'init-failed';
return;
}
if (ctx.state === 'suspended') { ctx.resume(); }
// Match the canvas backing-store to its CSS size × DPR for crispness.
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, canvas.offsetWidth * dpr);
canvas.height = Math.max(1, canvas.offsetHeight * dpr);
var c2d = canvas.getContext('2d');
var data = new Uint8Array(analyser.frequencyBinCount);
var bars = player.querySelector('.radio-player__bars');
// Stash on the player so play/pause/error handlers can drive it.
player._viz = {
ctx: ctx,
analyser: analyser,
canvas: canvas,
c2d: c2d,
data: data,
bars: bars,
hasData: false,
firstAt: 0,
raf: null
};
player._vizState = 'ok'; // optimistic; flipped to 'cors-blocked' if data stays zero
}
function startVizLoop(player, audio) {
if (player._vizState !== 'ok' || !player._viz) { return; }
var v = player._viz;
if (v.raf) { return; } // already running
if (v.ctx.state === 'suspended') { v.ctx.resume(); }
v.firstAt = Date.now();
var color = (getComputedStyle(player).getPropertyValue('--wp-admin-theme-color') || '#2271b1').trim() || '#2271b1';
function draw() {
if (audio.paused) { v.raf = null; return; }
v.analyser.getByteFrequencyData(v.data);
// Detect whether we're getting real audio (non-zero data). Without
// proper CORS the analyser returns all-zeros silently.
if (!v.hasData) {
for (var j = 0; j < v.data.length; j++) {
if (v.data[j] > 0) { v.hasData = true; break; }
}
if (!v.hasData && Date.now() - v.firstAt > 2000) {
// Two seconds of silence from the analyser = CORS blocked.
// Fall back to the CSS bars and stop trying.
player._vizState = 'cors-blocked';
v.canvas.hidden = true;
if (v.bars) { v.bars.hidden = false; }
v.raf = null;
return;
}
}
if (v.hasData) {
// First frame with data → swap from CSS bars to canvas.
if (v.bars && !v.bars.hidden) {
v.bars.hidden = true;
v.canvas.hidden = false;
}
v.c2d.clearRect(0, 0, v.canvas.width, v.canvas.height);
v.c2d.fillStyle = color;
var bw = v.canvas.width / v.data.length;
for (var i = 0; i < v.data.length; i++) {
var h = (v.data[i] / 255) * v.canvas.height;
v.c2d.fillRect(i * bw, v.canvas.height - h, Math.max(1, bw - 1), h);
}
}
v.raf = requestAnimationFrame(draw);
}
v.raf = requestAnimationFrame(draw);
}
function stopVizLoop(player) {
if (player._viz && player._viz.raf) {
cancelAnimationFrame(player._viz.raf);
player._viz.raf = null;
}
}
/** Mute toggle. Remembers the volume at mute-time so unmute restores it. */
function bindMute(player, audio, volumeIn, volPctEl) {
var muteBtn = player.querySelector('[data-radio-mute]');
if (!muteBtn) { return; }
muteBtn.addEventListener('click', function () {
var icon = muteBtn.querySelector('.dashicons');
if (audio.muted || audio.volume === 0) {
// Unmute → restore stored prior volume (default 0.6 if we never had one)
var prev = parseFloat(muteBtn.dataset.prevVolume || '0.6');
audio.muted = false;
audio.volume = prev;
if (volumeIn) { volumeIn.value = Math.round(prev * 100); }
if (volPctEl) { volPctEl.textContent = Math.round(prev * 100) + '%'; }
muteBtn.classList.remove('radio-player__mute--muted');
muteBtn.setAttribute('aria-label', cfg.strings.mute || 'Mute');
if (icon) { icon.className = 'dashicons dashicons-controls-volumeon'; }
} else {
// Mute → store current volume, drop to 0
muteBtn.dataset.prevVolume = String(audio.volume);
audio.muted = true;
muteBtn.classList.add('radio-player__mute--muted');
muteBtn.setAttribute('aria-label', cfg.strings.unmute || 'Unmute');
if (icon) { icon.className = 'dashicons dashicons-controls-volumeoff'; }
}
});
}
/** Wire up one .radio-player surface (widget or main). */
function bindPlayer(player) {
var audio = player.querySelector('[data-radio-audio]');
var playBtn = player.querySelector('[data-radio-play]');
var stationSel = player.querySelector('[data-radio-station]');
var volumeIn = player.querySelector('[data-radio-volume]');
var nameEl = player.querySelector('[data-radio-name]');
var descEl = player.querySelector('[data-radio-desc]');
var volPctEl = player.querySelector('[data-radio-volume-pct]');
if (!audio || !playBtn || !stationSel) { return; }
var currentId = stationSel.value;
var station = findStation(currentId);
if (station) {
audio.src = station.url;
audio.volume = parseFloat(cfg.state && cfg.state.volume != null ? cfg.state.volume : 0.6);
updateMediaSession(audio, station);
}
if (volumeIn) { volumeIn.value = Math.round(audio.volume * 100); }
playBtn.addEventListener('click', function () {
showError(player, null);
if (audio.paused) {
var p = audio.play();
if (p && p.catch) {
p.catch(function (err) {
if (err && err.name === 'NotAllowedError') {
showError(player, cfg.strings.error || 'Click play again to start.');
} else {
showError(player, cfg.strings.error || 'Stream error.');
}
});
}
} else {
audio.pause();
}
});
audio.addEventListener('play', function () {
setPlayIcon(playBtn, true);
player.classList.add('is-playing'); // CSS dancing-bars animation
tryVisualizer(player, audio); // idempotent — only initialises once
startVizLoop(player, audio); // no-op unless viz state is 'ok'
startTrackPolling(player, findStation(stationSel.value));
});
audio.addEventListener('pause', function () {
setPlayIcon(playBtn, false);
player.classList.remove('is-playing');
stopVizLoop(player);
stopTrackPolling(player);
});
audio.addEventListener('error', function () {
showError(player, cfg.strings.error || 'Stream error.');
setPlayIcon(playBtn, false);
player.classList.remove('is-playing');
stopVizLoop(player);
stopTrackPolling(player);
});
audio.addEventListener('canplay', function () { showError(player, null); });
stationSel.addEventListener('change', function () {
var newId = stationSel.value;
var s = findStation(newId);
if (!s) { return; }
var wasPlaying = !audio.paused;
audio.pause();
audio.src = s.url;
audio.load();
if (nameEl) { nameEl.textContent = s.name; }
if (descEl) { descEl.textContent = s.description || ''; }
var genreEl = player.querySelector('[data-radio-genre]');
if (genreEl) { genreEl.textContent = s.genre || ''; }
stopTrackPolling(player); // clear last station's track
updateMediaSession(audio, s);
mirrorSelection(player, newId);
saveState({ station_id: newId }, player);
if (wasPlaying) {
audio.play().catch(function () { showError(player, cfg.strings.error || 'Stream error.'); });
}
});
if (volumeIn) {
volumeIn.addEventListener('input', function () {
var pct = parseInt(volumeIn.value, 10) || 0;
var vol = Math.max(0, Math.min(100, pct)) / 100;
audio.muted = false;
audio.volume = vol;
if (volPctEl) { volPctEl.textContent = pct + '%'; }
// Manual volume change exits mute state visibly.
var muteBtn = player.querySelector('[data-radio-mute]');
if (muteBtn) {
muteBtn.classList.remove('radio-player__mute--muted');
var icon = muteBtn.querySelector('.dashicons');
if (icon) { icon.className = 'dashicons dashicons-controls-volumeon'; }
}
mirrorVolume(player, pct);
clearTimeout(volumeIn._saveTimer);
volumeIn._saveTimer = setTimeout(function () {
saveState({ volume: vol }, player);
}, 400);
});
}
bindMute(player, audio, volumeIn, volPctEl);
}
/** Keep all .radio-player surfaces on the same station. */
function mirrorSelection(originPlayer, stationId) {
document.querySelectorAll('.radio-player').forEach(function (other) {
if (other === originPlayer) { return; }
var sel = other.querySelector('[data-radio-station]');
if (sel && sel.value !== stationId) {
sel.value = stationId;
var s = findStation(stationId);
if (s) {
var nm = other.querySelector('[data-radio-name]');
var dc = other.querySelector('[data-radio-desc]');
var gn = other.querySelector('[data-radio-genre]');
if (nm) { nm.textContent = s.name; }
if (dc) { dc.textContent = s.description || ''; }
if (gn) { gn.textContent = s.genre || ''; }
}
}
});
}
/** Keep volume sliders in sync across surfaces. */
function mirrorVolume(originPlayer, pct) {
document.querySelectorAll('.radio-player').forEach(function (other) {
if (other === originPlayer) { return; }
var vol = other.querySelector('[data-radio-volume]');
if (vol && parseInt(vol.value, 10) !== pct) {
vol.value = pct;
var label = other.querySelector('[data-radio-volume-pct]');
if (label) { label.textContent = pct + '%'; }
var au = other.querySelector('[data-radio-audio]');
if (au) { au.volume = pct / 100; au.muted = false; }
}
});
}
/** Wire the Settings page volume slider's live percentage label.
* Replaces the inline `oninput` attribute removed in v0.3.0. */
function bindSettingsSlider() {
var slider = document.getElementById('default_volume');
var label = document.getElementById('default_volume_label');
if (!slider || !label) { return; }
slider.addEventListener('input', function () {
label.textContent = slider.value + '%';
});
}
/** v0.5.0: wire the History admin page — filter, favourite-toggle,
* clear-history. No-op on pages without these elements. */
function bindHistoryPage() {
var searchIn = document.getElementById('radio-history-search');
var stationSel = document.getElementById('radio-history-station');
var rows = document.querySelectorAll('.radio-history-row');
var clearBtn = document.getElementById('radio-history-clear');
function applyFilter() {
if (!rows.length) { return; }
var q = (searchIn ? searchIn.value : '').toLowerCase().trim();
var s = stationSel ? stationSel.value : '';
rows.forEach(function (row) {
var matchQ = !q || (row.dataset.search || '').indexOf(q) !== -1;
var matchS = !s || row.dataset.stationId === s;
row.classList.toggle('is-filtered', !(matchQ && matchS));
});
}
if (searchIn) { searchIn.addEventListener('input', applyFilter); }
if (stationSel) { stationSel.addEventListener('change', applyFilter); }
// Favourite-toggle buttons (per row)
document.querySelectorAll('.radio-fav-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var fd = new FormData();
fd.append('action', 'radio_toggle_favourite');
fd.append('nonce', btn.dataset.nonce || '');
fd.append('artist', btn.dataset.artist || '');
fd.append('title', btn.dataset.title || '');
fd.append('station', btn.dataset.station || '');
fd.append('station_id', btn.dataset.stationId || '');
btn.disabled = true;
fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: fd })
.then(function (r) { return r.json(); })
.then(function (res) {
btn.disabled = false;
if (!res || !res.success) { return; }
var isFav = !!(res.data && res.data.favourite);
btn.classList.toggle('is-fav', isFav);
btn.textContent = isFav ? '★' : '☆';
btn.setAttribute('aria-label', isFav
? (cfg.strings.removeFav || 'Remove from favourites')
: (cfg.strings.addFav || 'Add to favourites'));
})
.catch(function () { btn.disabled = false; });
});
});
// Clear-history button (history tab only)
if (clearBtn) {
clearBtn.addEventListener('click', function () {
var msg = cfg.strings.clearConfirm || 'Clear all track history? (Favourites are kept.)';
if (!window.confirm(msg)) { return; }
var fd = new FormData();
fd.append('action', 'radio_clear_history');
fd.append('nonce', clearBtn.dataset.nonce || '');
clearBtn.disabled = true;
fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: fd })
.then(function (r) { return r.json(); })
.then(function (res) {
if (res && res.success) { window.location.reload(); }
else { clearBtn.disabled = false; }
})
.catch(function () { clearBtn.disabled = false; });
});
}
}
function init() {
var players = document.querySelectorAll('.radio-player');
for (var i = 0; i < players.length; i++) { bindPlayer(players[i]); }
bindSettingsSlider();
bindHistoryPage();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();