chore: initial commit — Radio v0.1.0 (Phase A complete)
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>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user