Files
rangerhq-radio/assets/js/radio.js
T
ranger 0dc3a220d9 feat(0.6.0): pop-out mini-player — continuous background play across admin nav
Until v0.5.0 the audio cut every time you navigated between WP admin
pages — every navigation is a full page reload, which destroys the
<audio> element. v0.6.0 fixes the background-music use case by letting
you pop the player out into a separate browser window that persists
across the parent tab's navigation.

Pop-out window
  - Small "↗ Pop out" button beside the Play button on both the main
    Radio page and the Dashboard widget.
  - Opens a 380×560 standalone window via window.open() with a window
    name of `radio_popout` so a second click re-focuses the existing
    popup rather than spawning a new one.
  - Popup renders at admin-post.php?action=radio_popout&play=1 — a new
    admin_post_radio_popout hook outputs a full standalone HTML page
    (custom DOCTYPE / head / body, no WP admin chrome).
  - Theme follows radio_state['theme'] via body class radio-theme-{...}.
  - Includes the full player (now-playing block with dancing bars +
    Web Audio visualizer, play, mute, volume, station dropdown, error
    slot). Close button calls window.close().

Auto-resume + single-stream invariant
  - cfg.autoPlay is true when the popup URL carries &play=1. radio.js
    auto-calls audio.play() 200 ms after init. Same-origin user-gesture
    popups are exempt from autoplay-blocking on every modern browser.
  - On pop-out click, every other audio surface in the parent tab is
    paused so there is only ever one stream playing.

State stays in sync
  - Popup uses the SAME radio_save_state AJAX + track-logging endpoint.
    Station / volume changes persist to user_meta; track history keeps
    accumulating from whichever surface is playing.

Edge cases
  - Inside the popup, cfg.popoutUrl is '' so bindPopOut hides any
    Pop-out button it finds (would be infinite otherwise).
  - Popup blocked by browser → clear alert tells the user to allow
    popups for this site.

Files
  - radio.php (version, popoutUrl in localized config, admin_post
    handler + full standalone HTML renderer)
  - inc/admin-page.php + inc/dashboard-widget.php (Pop-out button
    beside Play)
  - assets/css/radio.css (.radio-player__popout styling)
  - assets/js/radio.js (bindPopOut function; autoPlay branch in
    bindPlayer)
  - inc/about.php + CHANGELOG.md (history entries)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:03:21 +01:00

582 lines
26 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);
bindPopOut(player);
// v0.6.0: popout auto-resume — when opened with ?play=1, immediately
// pick up where the main tab left off. Same-origin user-gesture popups
// are exempt from autoplay blocking on every modern browser.
if (cfg.autoPlay) {
setTimeout(function () {
if (audio.paused) { audio.play().catch(function () { /* autoplay denied — user just clicks play */ }); }
}, 200);
}
}
/** v0.6.0: Pop-out window button. Opens a 380×560 standalone window
* with just the player chrome (no WP admin), via admin-post.php?
* action=radio_popout&play=1. The popup persists across main-tab
* navigation so background music doesn't cut when you move around
* the WP admin. Pauses every other audio surface in this tab so
* there's only one stream playing at a time. */
function bindPopOut(player) {
var btn = player.querySelector('[data-radio-popout]');
if (!btn) { return; }
if (!cfg.popoutUrl) { btn.style.display = 'none'; return; } // already in popout
btn.addEventListener('click', function () {
var w = window.open(
cfg.popoutUrl + '&play=1',
'radio_popout',
'width=380,height=560,resizable=yes,scrollbars=no,toolbar=no,location=no,menubar=no,status=no'
);
if (!w) {
window.alert('Pop-out blocked by the browser. Allow popups for this site, then try again.');
return;
}
w.focus();
// Pause every audio surface in this tab — the popup is the new player.
document.querySelectorAll('[data-radio-audio]').forEach(function (a) {
if (!a.paused) { a.pause(); }
});
});
}
/** 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();
}
})();