Files
ranger de93aa50ca v0.7.4 — wp.org submission cleanup: remove updater + add LICENSE
Walks back v0.7.3's Update URI guard pattern. Plugin Check raised
`plugin_updater_detected` on the v0.7.3 build:

    "Including An Update Checker / Changing Updates functionality.
     Plugin Updater detected. Use of the Update URI header is not
     allowed in plugins hosted on WordPress.org."

PCP scans the source as-shipped, not as-distributed, so the v0.7.3
build-time `sed` strip never had a chance to run before the scan.
The simpler correct answer is to delete the custom updater entirely
and rely on wp.org as the canonical update channel once accepted.

Removed:
* `inc/updater.php` — recoverable from git history at tag v0.7.3 if
  ever needed for a non-wp.org distribution.
* `Update URI:` header line in `radio.php` (plus the NOTE block).
* `require_once RADIO_PATH . 'inc/updater.php';` in `radio.php`.
* Updates panel render + `function_exists()` guard in `inc/settings.php`.
* "Self-hosted update checker" line in `README.md`.
* "Self-hosted updater" bullet in `readme.txt` Privacy section.

Added (GPL declaration loop closed):
* `LICENSE` — verbatim canonical GPL v2 text (338 lines from
  gnu.org/licenses/gpl-2.0.txt).
* GPL header block in `assets/css/radio.css`.
* GPL header block in `assets/js/radio.js` (original module overview
  preserved verbatim below the license header).
* GPL header block in `radio.php` docblock alongside the existing
  `License:` / `License URI:` fields.

Migration: existing Gitea-installed copies of v0.7.3 or earlier
become orphaned of auto-updates after this lands on them (the
updater code is gone, so nothing advertises newer versions back).
Recommended path is to uninstall + reinstall from wp.org once the
plugin is accepted. No data loss — station + volume + theme +
history + favourites all live in user_meta.

No user-visible behaviour changes for the player itself. Only the
small `Updates` panel that sat at the bottom of Radio → Settings
is gone.
2026-05-30 04:43:16 +01:00

592 lines
26 KiB
JavaScript
Raw Permalink 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.
/*!
* RangerHQ Radio — vanilla JS audio controller.
*
* Copyright (C) 2026 David Keane
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version. See LICENSE in the plugin root
* for the full text, or visit https://www.gnu.org/licenses/gpl-2.0.html
*
* --- module overview ---
*
* 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();
}
})();