a22ddfb6d3
First release of a-radio — a free, focused SomaFM player for the
WordPress admin. Extracted-and-rebuilt from RangerPlex's RadioPlayer
component, simplified along the way (no Node CORS proxy needed; the
browser plays SomaFM streams directly).
Phase A deliverables — all in this commit:
PLAYER SURFACES
- Dashboard widget on WP Admin → Dashboard (compact: play/pause +
station select + volume).
- Dedicated admin page at WP Admin → Radio → My Radio (larger
layout, station genre badge, volume %% display).
- Both surfaces share the same JS — picking a station on one mirrors
to the other within the same admin session.
STATIONS
- 44 SomaFM stations grouped by 10 genres (Ambient / Electronic /
Lounge / Rock / Metal / Jazz / World / Reggae / Holiday / Specials).
- Ported verbatim from RangerPlex's RadioPlayer.tsx.
- Default station: Groove Salad (most popular SomaFM channel, safe
ambient/coding pick).
STATE
- Per-user state stored in user_meta under key `radio_state`:
{ station_id, volume, theme, last_played_at }.
- Settings page lets the user pick default station, default volume,
theme (auto/light/dark), and hide the dashboard widget.
- AJAX endpoint `radio_save_state` persists changes without a page
reload. Nonce-protected, capability-checked, only known keys
accepted, station validated against the list, volume clamped to
[0,1].
UPDATES
- Self-hosted updater wired to Gitea (ranger/a-radio) from commit 1.
- Direct port of Buddy's inc/updater.php with BUDDY_* → RADIO_* and
buddy_* → radio_* renames. Same 12h-success / 1h-error caching.
ARCHITECTURE
- No `wp` prefix (trademark policy).
- GPL v2+ public Gitea repo from day one.
- Vanilla JS only — no React, no webpack, no minified-only files.
- CSS-only animations, all assets local.
- Single H1 per admin page.
- Direct HTML5 <audio> playback (SomaFM has CORS headers; no PHP
audio proxy needed). This is the key simplification vs RangerPlex.
COMPLIANCE
- "Powered by SomaFM" credit displayed on both player surfaces with
link to somafm.com. About page invites users to donate to SomaFM
directly.
PHASE PROGRESSION (not in this commit)
- Phase B — Settings polish + retry on transient stream errors +
README.md formatted for WP.org submission.
- Phase C — Now-playing metadata via SomaFM's per-station song
history endpoint (this is where the RangerPlex proxy logic ports
to — server-side, for metadata not audio).
- Phase D — [ranger_radio] shortcode for frontend embedding.
- Phase E — Favorites system.
- Phase F — Multi-provider (Radio Paradise / NTS / KEXP / BBC).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
195 lines
7.7 KiB
JavaScript
195 lines
7.7 KiB
JavaScript
/**
|
|
* 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.
|
|
*
|
|
* No build step. No dependencies. Plain ES5 + a few ES6 features that
|
|
* every browser supporting <audio> already has.
|
|
*/
|
|
(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;
|
|
}
|
|
|
|
/** Persist a state patch back to the server. Best-effort: a failed
|
|
* save doesn't block the local UI from updating. */
|
|
function saveState(patch) {
|
|
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
|
|
}).catch(function () { /* swallow — local UI already updated */ });
|
|
}
|
|
|
|
/** Update play/pause button icon to reflect current audio state. */
|
|
function setPlayIcon(btn, playing) {
|
|
var icon = btn.querySelector('.dashicons');
|
|
if (!icon) { return; }
|
|
icon.className = 'dashicons ' + (playing ? 'dashicons-controls-pause' : 'dashicons-controls-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;
|
|
}
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
|
|
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') {
|
|
// Autoplay was blocked — user has to click play first.
|
|
// Since this IS the click handler, this branch is rare;
|
|
// log it and surface a polite message.
|
|
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); });
|
|
audio.addEventListener('pause', function () { setPlayIcon(playBtn, false); });
|
|
audio.addEventListener('error', function () { showError(player, cfg.strings.error || 'Stream error.'); setPlayIcon(playBtn, false); });
|
|
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 || ''; }
|
|
// Mirror selection to any OTHER .radio-player surfaces on the page.
|
|
mirrorSelection(player, newId);
|
|
saveState({ station_id: newId });
|
|
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.volume = vol;
|
|
if (volPctEl) { volPctEl.textContent = pct + '%'; }
|
|
mirrorVolume(player, pct);
|
|
// Debounced save — save once 400ms after the user stops dragging.
|
|
clearTimeout(volumeIn._saveTimer);
|
|
volumeIn._saveTimer = setTimeout(function () {
|
|
saveState({ volume: vol });
|
|
}, 400);
|
|
});
|
|
}
|
|
}
|
|
|
|
/** 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]');
|
|
if (nm) { nm.textContent = s.name; }
|
|
if (dc) { dc.textContent = s.description || ''; }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/** 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; }
|
|
}
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
var players = document.querySelectorAll('.radio-player');
|
|
for (var i = 0; i < players.length; i++) {
|
|
bindPlayer(players[i]);
|
|
}
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|