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,7 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
*.bak
|
||||
.idea/
|
||||
.vscode/
|
||||
node_modules/
|
||||
@@ -0,0 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **Radio** are documented here.
|
||||
Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versioning: [SemVer](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2026-05-26
|
||||
|
||||
**Radio is born.** First release of a new standalone WordPress plugin extracted-and-rebuilt from the radio feature that lives inside RangerPlex. Radio stands on its own as a focused, friendly companion plugin for the WordPress dashboard — a tab of background music while you work.
|
||||
|
||||
### Added — Phase A complete (player exists)
|
||||
- **Dashboard widget** at WP Admin → Dashboard showing a compact mini-player with play/pause, station select grouped by genre, and a volume slider.
|
||||
- **Dedicated admin page** at WP Admin → Radio → My Radio showing the same controls in a larger format with the station's genre badge and description.
|
||||
- **44 SomaFM stations** across **10 genres** (Ambient, Electronic, Lounge, Rock, Metal, Jazz, World, Reggae, Holiday, Specials). Stream URLs use SomaFM's public 128kbps MP3 endpoints — no proxy server required.
|
||||
- **Per-user state storage** via `user_meta` (key: `radio_state`). Each WordPress admin remembers their own station choice, volume, and theme preference.
|
||||
- **Settings page** at WP Admin → Radio → Settings with default station, default volume, theme (auto/light/dark), and an opt-out for the dashboard widget. Updates panel shown to admins with `manage_options`.
|
||||
- **About page** at WP Admin → Radio → About with plain-language explanation of what the plugin does, who it's for, version history, and credits to SomaFM.
|
||||
- **Self-hosted update checker** wired up to the Gitea repo (`ranger/a-radio`) from commit 1. Polls `/api/v1/repos/ranger/a-radio/releases/latest` with a `/tags?limit=1` fallback. 12h success cache, 1h negative cache.
|
||||
- **AJAX endpoint** `radio_save_state` for persisting station/volume changes without a page reload. Nonce-protected, capability-checked.
|
||||
- **Custom admin-menu icon** (`dashicons-format-audio`).
|
||||
- **Direct HTML5 `<audio>` playback** — no Node proxy, no PHP stream-passthrough, no server-side resource cost per listener. SomaFM's CORS headers make this work out of the box in modern browsers.
|
||||
|
||||
### Architecture (locked from day one)
|
||||
- **Single-word brand name `Radio`** — no "WP" prefix, no marketplace trademark hurdle.
|
||||
- **Public GPL v2+ Gitea repo** at `ranger/a-radio` on `git.davidtkeane.com`.
|
||||
- **Per-user state in `user_meta`** under key `radio_state`.
|
||||
- **Vanilla JS only** — no React, no build step, no bundler. ~200 lines of JS controlling all interactions.
|
||||
- **CSS-only animations, all assets local** — bundle stays sub-100KB.
|
||||
- **Single H1 per admin page**, no nested toggle boxes — Tier-1 discipline carried forward from the Logbook + Buddy lineage.
|
||||
- **Sanitize on input, escape on output** throughout. Every AJAX endpoint nonce-protected and capability-checked.
|
||||
|
||||
### Compliance
|
||||
- Station list and stream URLs are SomaFM's public, freely-published endpoints. Their terms allow redistribution with attribution.
|
||||
- "Powered by SomaFM" credit displayed in both player surfaces, linking to somafm.com.
|
||||
- The About page invites users to donate to SomaFM directly.
|
||||
|
||||
### Not in this release (planned)
|
||||
- Phase B — Settings polish + README for WP.org submission + retry logic for transient stream errors.
|
||||
- Phase C — Now-playing metadata via SomaFM's per-station song-history endpoint.
|
||||
- Phase D — `[ranger_radio]` shortcode so the player can be embedded in posts/pages.
|
||||
- Phase E — Favorites system.
|
||||
- Phase F — Multi-provider (Radio Paradise, NTS Radio, KEXP, BBC) with a provider abstraction.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Radio — SomaFM player for your WordPress dashboard
|
||||
|
||||
A small, focused, free WordPress plugin that drops a SomaFM radio player into your admin dashboard. 44 hand-curated stations across 10 genres — ambient, electronic, lounge, rock, metal, jazz, world, reggae, holiday, and specials.
|
||||
|
||||
## What it does
|
||||
|
||||
- Mini-player on **WP Admin → Dashboard** (opt-out via Settings)
|
||||
- Dedicated page at **WP Admin → Radio → My Radio**
|
||||
- Plays via HTML5 `<audio>` — no proxy server, no third-party tracking
|
||||
- Per-user state: each admin remembers their own station + volume
|
||||
- Self-hosted update checker against the Gitea repo
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the latest release `.zip` from [git.davidtkeane.com/ranger/a-radio/releases](https://git.davidtkeane.com/ranger/a-radio/releases)
|
||||
2. WP Admin → Plugins → Add New → Upload Plugin
|
||||
3. Activate
|
||||
4. Look at **WP Admin → Dashboard** for the mini-player, or **WP Admin → Radio**
|
||||
|
||||
## Requirements
|
||||
|
||||
- WordPress 5.0+
|
||||
- PHP 7.4+
|
||||
- A modern browser (Chrome 90+, Firefox 88+, Safari 14+, Edge 90+)
|
||||
|
||||
## Credits
|
||||
|
||||
All stations and streams are provided by [SomaFM](https://somafm.com/), an independent, listener-supported, commercial-free internet radio network broadcasting from San Francisco since 2000. If you enjoy this plugin, please consider [donating to SomaFM directly](https://somafm.com/support/).
|
||||
|
||||
## License
|
||||
|
||||
GPL v2 or later. See `LICENSE` (the same GPL v2+ terms as WordPress itself).
|
||||
|
||||
## Author
|
||||
|
||||
David Keane — part of the RangerHQ plugin family. [rangersmyth.xyz](https://rangersmyth.xyz/)
|
||||
@@ -0,0 +1,228 @@
|
||||
/* Radio — admin styles */
|
||||
|
||||
.radio-player {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.radio-player__now {
|
||||
text-align: center;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.radio-player__now--large {
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.radio-player__label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #646970;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.radio-player__station-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
}
|
||||
|
||||
.radio-player__now--large .radio-player__station-name {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.radio-player__station-desc {
|
||||
font-size: 13px;
|
||||
color: #50575e;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.radio-player__station-genre {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
padding: 2px 10px;
|
||||
background: #f0f0f1;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
color: #2c3338;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.radio-player__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.radio-player__controls--large {
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.radio-player__play {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50% !important;
|
||||
padding: 0 !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.radio-player__play .dashicons {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.radio-player__controls--large .radio-player__play {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.radio-player__controls--large .radio-player__play .dashicons {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.radio-player__volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.radio-player__volume input[type="range"] {
|
||||
flex: 1;
|
||||
accent-color: #2271b1;
|
||||
}
|
||||
|
||||
.radio-player__volume-pct {
|
||||
min-width: 36px;
|
||||
font-size: 12px;
|
||||
color: #50575e;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.radio-player__station-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.radio-player__station-select label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #646970;
|
||||
}
|
||||
|
||||
.radio-player__station-select select {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.radio-player__error {
|
||||
color: #b32d2e;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
background: #fcf0f1;
|
||||
border-left: 3px solid #b32d2e;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.radio-player__credit {
|
||||
font-size: 12px;
|
||||
color: #646970;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.radio-player__credit--main {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.radio-player__credit a {
|
||||
color: #2271b1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.radio-player__credit a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Main page wraps the player at a comfortable max width. */
|
||||
.radio-wrap .radio-player--main {
|
||||
max-width: 640px;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #ccd0d4;
|
||||
border-radius: 6px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.radio-intro {
|
||||
max-width: 640px;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
/* About page layout */
|
||||
.radio-about-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 18px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.radio-about-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ccd0d4;
|
||||
border-radius: 6px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.radio-about-card h2 {
|
||||
margin-top: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.radio-about-card--versions ul {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.radio-about-card--versions li {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.radio-about-card--versions .ver {
|
||||
font-weight: 600;
|
||||
color: #2271b1;
|
||||
}
|
||||
|
||||
.radio-about-card--versions .latest {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
background: #00a32a;
|
||||
color: #fff;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.radio-about-changelog-link {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* Radio — About page (WP Admin → Radio → About).
|
||||
*
|
||||
* Plain-language explanation of what the plugin does + version
|
||||
* history + link to the CHANGELOG.md on Gitea. Pattern mirrors
|
||||
* a-buddy/inc/about.php.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
function radio_render_about_page() {
|
||||
if ( ! current_user_can( 'read' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view this page.', 'radio' ) );
|
||||
}
|
||||
|
||||
$count = count( radio_get_stations_flat() );
|
||||
?>
|
||||
<div class="wrap radio-about-wrap">
|
||||
<h1><?php esc_html_e( 'About Radio', 'radio' ); ?></h1>
|
||||
|
||||
<div class="radio-about-grid">
|
||||
|
||||
<div class="radio-about-card">
|
||||
<h2><?php esc_html_e( 'What Radio does', 'radio' ); ?></h2>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d = station count */
|
||||
esc_html__( 'Adds a small, focused radio player to your WordPress dashboard. %d hand-curated SomaFM stations across 10 genres — ambient, electronic, lounge, rock, metal, jazz, world, reggae, holiday and specials. Plays in your admin pages while you work. Your chosen station + volume persist per user.', 'radio' ),
|
||||
(int) $count
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<p>
|
||||
<?php esc_html_e( 'Audio plays directly in your browser via HTML5 — no server-side proxy, no extra services to host, no third-party tracking. Just an <audio> element pointed at SomaFM\'s public streams.', 'radio' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="radio-about-card">
|
||||
<h2><?php esc_html_e( 'Who Radio is for', 'radio' ); ?></h2>
|
||||
<p>
|
||||
<?php esc_html_e( 'Anyone who likes background music while working in the WordPress admin. Coders, writers, support agents, content editors. The 44 SomaFM stations cover a wide enough range that there\'s something for any mood — from coding-focus ambient (Groove Salad, Drone Zone) to drive-time electronic (DEF CON Radio, Beat Blender) to mellow lounge (Lush, Secret Agent) to specifically-quirky picks (SF 10-33 mixes ambient with San Francisco public-safety radio).', 'radio' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="radio-about-card radio-about-card--versions">
|
||||
<h2><?php esc_html_e( 'Version history', 'radio' ); ?></h2>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="ver">v0.1.0</span> — 26 May 2026 <span class="latest">latest</span><br>
|
||||
<?php esc_html_e( 'First release. 44 SomaFM stations grouped by 10 genres, dashboard widget + dedicated admin page, per-user state in user_meta, self-hosted update checker against Gitea. Direct HTML5 audio playback — no proxy, no build step, no tracking.', 'radio' ); ?>
|
||||
</li>
|
||||
</ul>
|
||||
<a class="radio-about-changelog-link"
|
||||
href="https://git.davidtkeane.com/ranger/a-radio/src/branch/main/CHANGELOG.md"
|
||||
target="_blank" rel="noopener">
|
||||
<?php esc_html_e( 'View the full CHANGELOG.md →', 'radio' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="radio-about-card">
|
||||
<h2><?php esc_html_e( 'Credits + thanks', 'radio' ); ?></h2>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
wp_kses(
|
||||
/* translators: %s = link to somafm.com */
|
||||
__( 'All stations and streams are provided by %s — an independent, listener-supported, commercial-free internet radio network broadcasting from San Francisco since 2000. Radio is just a small WordPress wrapper around their public streams. If you enjoy this plugin, please consider donating to SomaFM directly.', 'radio' ),
|
||||
array( 'a' => array( 'href' => true, 'target' => true, 'rel' => true ) )
|
||||
),
|
||||
'<a href="https://somafm.com/support/" target="_blank" rel="noopener">SomaFM</a>'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<p>
|
||||
<?php esc_html_e( 'Plugin author: David Keane. Part of the RangerHQ plugin family. GPL v2 or later. Source on Gitea.', 'radio' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Radio — main admin page (WP Admin → Radio → My Radio).
|
||||
*
|
||||
* Larger player with the same controls as the dashboard widget. Both
|
||||
* surfaces share the same JS (assets/js/radio.js binds to every
|
||||
* .radio-player on the page).
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
function radio_render_main_page() {
|
||||
if ( ! current_user_can( 'read' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view this page.', 'radio' ) );
|
||||
}
|
||||
|
||||
$state = radio_get_state();
|
||||
$station = radio_find_station( $state['station_id'] );
|
||||
$stations = radio_get_stations_grouped();
|
||||
$count = count( radio_get_stations_flat() );
|
||||
?>
|
||||
<div class="wrap radio-wrap">
|
||||
<h1><?php esc_html_e( 'Radio', 'radio' ); ?></h1>
|
||||
|
||||
<p class="radio-intro">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d = number of stations */
|
||||
esc_html__( 'A friendly tab of background music for your WordPress admin. %d hand-curated SomaFM stations across 10 genres, all free, no ads.', 'radio' ),
|
||||
(int) $count
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
|
||||
<div class="radio-player radio-player--main" data-radio-surface="main">
|
||||
<div class="radio-player__now radio-player__now--large">
|
||||
<div class="radio-player__label"><?php esc_html_e( 'Now Playing', 'radio' ); ?></div>
|
||||
<div class="radio-player__station-name" data-radio-name><?php echo esc_html( $station['name'] ); ?></div>
|
||||
<div class="radio-player__station-desc" data-radio-desc><?php echo esc_html( $station['description'] ); ?></div>
|
||||
<div class="radio-player__station-genre"><?php echo esc_html( $station['genre'] ); ?></div>
|
||||
</div>
|
||||
|
||||
<div class="radio-player__controls radio-player__controls--large">
|
||||
<button type="button" class="radio-player__play button button-primary button-hero" data-radio-play title="<?php esc_attr_e( 'Play', 'radio' ); ?>">
|
||||
<span class="dashicons dashicons-controls-play"></span>
|
||||
</button>
|
||||
|
||||
<div class="radio-player__volume">
|
||||
<span class="dashicons dashicons-controls-volumeon"></span>
|
||||
<input type="range" min="0" max="100" value="<?php echo esc_attr( (int) round( $state['volume'] * 100 ) ); ?>" data-radio-volume aria-label="<?php esc_attr_e( 'Volume', 'radio' ); ?>">
|
||||
<span class="radio-player__volume-pct" data-radio-volume-pct><?php echo esc_html( (int) round( $state['volume'] * 100 ) ); ?>%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="radio-player__station-select">
|
||||
<label for="radio-station-main"><?php esc_html_e( 'Station', 'radio' ); ?></label>
|
||||
<select id="radio-station-main" data-radio-station>
|
||||
<?php foreach ( $stations as $genre => $entries ) :
|
||||
if ( empty( $entries ) ) { continue; }
|
||||
?>
|
||||
<optgroup label="<?php echo esc_attr( $genre ); ?>">
|
||||
<?php foreach ( $entries as $entry ) : ?>
|
||||
<option value="<?php echo esc_attr( $entry['id'] ); ?>" data-url="<?php echo esc_attr( $entry['url'] ); ?>" data-desc="<?php echo esc_attr( $entry['description'] ); ?>" data-genre="<?php echo esc_attr( $entry['genre'] ); ?>" <?php selected( $entry['id'], $state['station_id'] ); ?>>
|
||||
<?php echo esc_html( $entry['name'] ); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="radio-player__error" data-radio-error hidden></div>
|
||||
|
||||
<audio data-radio-audio preload="none"></audio>
|
||||
</div>
|
||||
|
||||
<p class="radio-player__credit radio-player__credit--main">
|
||||
<?php
|
||||
printf(
|
||||
wp_kses(
|
||||
/* translators: %s = link to somafm.com */
|
||||
__( 'Stations and streams provided by %s — an independent, listener-supported, commercial-free internet radio network. Please consider supporting them.', 'radio' ),
|
||||
array( 'a' => array( 'href' => true, 'target' => true, 'rel' => true ) )
|
||||
),
|
||||
'<a href="https://somafm.com/" target="_blank" rel="noopener">SomaFM</a>'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/**
|
||||
* Radio — dashboard widget.
|
||||
*
|
||||
* The compact mini-player on WP Admin → Dashboard. Same HTML structure
|
||||
* as the main admin page but smaller, so the assets/js/radio.js can
|
||||
* bind to whichever surface the user opens first.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
add_action( 'wp_dashboard_setup', 'radio_register_dashboard_widget' );
|
||||
function radio_register_dashboard_widget() {
|
||||
if ( ! current_user_can( 'read' ) ) { return; }
|
||||
|
||||
// Honour the per-user opt-out from Settings.
|
||||
$state = radio_get_state();
|
||||
if ( isset( $state['hide_dashboard_widget'] ) && $state['hide_dashboard_widget'] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_add_dashboard_widget(
|
||||
'radio_dashboard_widget',
|
||||
__( 'Radio', 'radio' ),
|
||||
'radio_render_dashboard_widget'
|
||||
);
|
||||
}
|
||||
|
||||
function radio_render_dashboard_widget() {
|
||||
$state = radio_get_state();
|
||||
$station = radio_find_station( $state['station_id'] );
|
||||
$stations = radio_get_stations_grouped();
|
||||
?>
|
||||
<div class="radio-player radio-player--widget" data-radio-surface="widget">
|
||||
<div class="radio-player__now">
|
||||
<div class="radio-player__label"><?php esc_html_e( 'Now Playing', 'radio' ); ?></div>
|
||||
<div class="radio-player__station-name" data-radio-name><?php echo esc_html( $station['name'] ); ?></div>
|
||||
<div class="radio-player__station-desc" data-radio-desc><?php echo esc_html( $station['description'] ); ?></div>
|
||||
</div>
|
||||
|
||||
<div class="radio-player__controls">
|
||||
<button type="button" class="radio-player__play button button-primary" data-radio-play title="<?php esc_attr_e( 'Play', 'radio' ); ?>">
|
||||
<span class="dashicons dashicons-controls-play"></span>
|
||||
</button>
|
||||
|
||||
<div class="radio-player__volume">
|
||||
<span class="dashicons dashicons-controls-volumeon"></span>
|
||||
<input type="range" min="0" max="100" value="<?php echo esc_attr( (int) round( $state['volume'] * 100 ) ); ?>" data-radio-volume aria-label="<?php esc_attr_e( 'Volume', 'radio' ); ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="radio-player__station-select">
|
||||
<label for="radio-station-widget"><?php esc_html_e( 'Station', 'radio' ); ?></label>
|
||||
<select id="radio-station-widget" data-radio-station>
|
||||
<?php foreach ( $stations as $genre => $entries ) :
|
||||
if ( empty( $entries ) ) { continue; }
|
||||
?>
|
||||
<optgroup label="<?php echo esc_attr( $genre ); ?>">
|
||||
<?php foreach ( $entries as $entry ) : ?>
|
||||
<option value="<?php echo esc_attr( $entry['id'] ); ?>" data-url="<?php echo esc_attr( $entry['url'] ); ?>" data-desc="<?php echo esc_attr( $entry['description'] ); ?>" <?php selected( $entry['id'], $state['station_id'] ); ?>>
|
||||
<?php echo esc_html( $entry['name'] ); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="radio-player__error" data-radio-error hidden></div>
|
||||
|
||||
<audio data-radio-audio preload="none"></audio>
|
||||
|
||||
<p class="radio-player__credit">
|
||||
<?php
|
||||
/* translators: %s = SomaFM link */
|
||||
printf(
|
||||
wp_kses(
|
||||
/* translators: %s = link to somafm.com */
|
||||
__( 'Powered by %s', 'radio' ),
|
||||
array( 'a' => array( 'href' => true, 'target' => true, 'rel' => true ) )
|
||||
),
|
||||
'<a href="https://somafm.com/" target="_blank" rel="noopener">SomaFM</a>'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
/**
|
||||
* Radio — Settings page.
|
||||
*
|
||||
* Lets the user pick default station + volume + theme + dashboard
|
||||
* widget opt-out. Renders the Updates panel from `updater.php` at the
|
||||
* bottom.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
function radio_render_settings_page() {
|
||||
if ( ! current_user_can( 'read' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view this page.', 'radio' ) );
|
||||
}
|
||||
|
||||
// Handle form submission.
|
||||
if ( isset( $_POST['radio_settings_submit'] )
|
||||
&& check_admin_referer( 'radio_save_settings', 'radio_settings_nonce' ) ) {
|
||||
|
||||
$patch = array();
|
||||
|
||||
if ( isset( $_POST['default_station'] ) ) {
|
||||
$patch['station_id'] = sanitize_key( wp_unslash( $_POST['default_station'] ) );
|
||||
}
|
||||
if ( isset( $_POST['default_volume'] ) ) {
|
||||
$patch['volume'] = max( 0.0, min( 1.0, ( (float) $_POST['default_volume'] ) / 100.0 ) );
|
||||
}
|
||||
if ( isset( $_POST['theme'] ) ) {
|
||||
$patch['theme'] = sanitize_key( wp_unslash( $_POST['theme'] ) );
|
||||
}
|
||||
|
||||
radio_update_state( $patch );
|
||||
|
||||
// Dashboard-widget opt-out is stored in state too (extra key).
|
||||
$hide_widget = ! empty( $_POST['hide_dashboard_widget'] );
|
||||
$user_id = get_current_user_id();
|
||||
$state = radio_get_state( $user_id );
|
||||
$state['hide_dashboard_widget'] = $hide_widget ? 1 : 0;
|
||||
update_user_meta( $user_id, RADIO_META_KEY, $state );
|
||||
|
||||
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Settings saved.', 'radio' ) . '</p></div>';
|
||||
}
|
||||
|
||||
$state = radio_get_state();
|
||||
$stations = radio_get_stations_grouped();
|
||||
$hide_widget = ! empty( $state['hide_dashboard_widget'] );
|
||||
?>
|
||||
<div class="wrap radio-settings-wrap">
|
||||
<h1><?php esc_html_e( 'Radio — Settings', 'radio' ); ?></h1>
|
||||
|
||||
<form method="post" action="">
|
||||
<?php wp_nonce_field( 'radio_save_settings', 'radio_settings_nonce' ); ?>
|
||||
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="default_station"><?php esc_html_e( 'Default station', 'radio' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<select id="default_station" name="default_station">
|
||||
<?php foreach ( $stations as $genre => $entries ) :
|
||||
if ( empty( $entries ) ) { continue; }
|
||||
?>
|
||||
<optgroup label="<?php echo esc_attr( $genre ); ?>">
|
||||
<?php foreach ( $entries as $entry ) : ?>
|
||||
<option value="<?php echo esc_attr( $entry['id'] ); ?>" <?php selected( $entry['id'], $state['station_id'] ); ?>>
|
||||
<?php echo esc_html( $entry['name'] ); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'The station that loads when you open Radio in a fresh tab.', 'radio' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="default_volume"><?php esc_html_e( 'Default volume', 'radio' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="range" id="default_volume" name="default_volume" min="0" max="100" value="<?php echo esc_attr( (int) round( $state['volume'] * 100 ) ); ?>" oninput="document.getElementById('default_volume_label').textContent = this.value + '%'">
|
||||
<span id="default_volume_label"><?php echo esc_html( (int) round( $state['volume'] * 100 ) ); ?>%</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="theme"><?php esc_html_e( 'Theme', 'radio' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<select id="theme" name="theme">
|
||||
<option value="auto" <?php selected( $state['theme'], 'auto' ); ?>><?php esc_html_e( 'Auto (match WP admin colour scheme)', 'radio' ); ?></option>
|
||||
<option value="light" <?php selected( $state['theme'], 'light' ); ?>><?php esc_html_e( 'Light', 'radio' ); ?></option>
|
||||
<option value="dark" <?php selected( $state['theme'], 'dark' ); ?>><?php esc_html_e( 'Dark', 'radio' ); ?></option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<?php esc_html_e( 'Dashboard widget', 'radio' ); ?>
|
||||
</th>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" name="hide_dashboard_widget" value="1" <?php checked( $hide_widget ); ?>>
|
||||
<?php esc_html_e( 'Hide the Radio widget from the WordPress Dashboard', 'radio' ); ?>
|
||||
</label>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'When checked, Radio is only accessible from the dedicated admin page (WP Admin → Radio → My Radio).', 'radio' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php submit_button( __( 'Save Changes', 'radio' ), 'primary', 'radio_settings_submit' ); ?>
|
||||
</form>
|
||||
|
||||
<?php
|
||||
// Updates panel — only manage_options users see it.
|
||||
if ( current_user_can( 'manage_options' ) ) {
|
||||
radio_render_updates_panel();
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
/**
|
||||
* Radio state — per-user persistence for station choice + volume.
|
||||
*
|
||||
* Storage: per-user, in `wp_usermeta` under key `radio_state`. A small
|
||||
* associative array. Picked user_meta over site-wide options so each
|
||||
* admin remembers their own preferred station — matches Buddy's
|
||||
* per-user model and the WordPress mental model.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
const RADIO_META_KEY = 'radio_state';
|
||||
|
||||
/**
|
||||
* Default state for a fresh listener. Used the first time a user
|
||||
* visits a Radio-rendering page.
|
||||
*
|
||||
* @return array { station_id, volume, theme, last_played_at }
|
||||
*/
|
||||
function radio_default_state() {
|
||||
return array(
|
||||
'station_id' => radio_default_station_id(), // Groove Salad
|
||||
'volume' => 0.6, // 60% — comfortable default
|
||||
'theme' => 'auto', // auto = match site admin colour scheme
|
||||
'last_played_at' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current user's state, falling back to defaults and
|
||||
* persisting them on first read so a baseline exists for AJAX patches.
|
||||
*
|
||||
* @param int $user_id Optional. Defaults to current user.
|
||||
* @return array
|
||||
*/
|
||||
function radio_get_state( $user_id = 0 ) {
|
||||
$user_id = $user_id ? (int) $user_id : get_current_user_id();
|
||||
if ( ! $user_id ) { return radio_default_state(); }
|
||||
|
||||
$state = get_user_meta( $user_id, RADIO_META_KEY, true );
|
||||
if ( ! is_array( $state ) || empty( $state ) ) {
|
||||
$state = radio_default_state();
|
||||
update_user_meta( $user_id, RADIO_META_KEY, $state );
|
||||
}
|
||||
|
||||
// Merge any newly-introduced default keys forward without trampling
|
||||
// existing user values.
|
||||
$state = array_merge( radio_default_state(), $state );
|
||||
|
||||
// Validate station — if it's been removed from stations.php, fall
|
||||
// back to the default rather than rendering a dead choice.
|
||||
if ( ! radio_find_station( $state['station_id'] ) ) {
|
||||
$state['station_id'] = radio_default_station_id();
|
||||
}
|
||||
|
||||
// Clamp volume to [0, 1].
|
||||
$state['volume'] = max( 0.0, min( 1.0, (float) $state['volume'] ) );
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a partial update to the current user's state. Only keys in
|
||||
* `radio_default_state()` are persisted; everything else is dropped.
|
||||
*
|
||||
* @param array $patch
|
||||
* @param int $user_id Optional. Defaults to current user.
|
||||
* @return array The new state.
|
||||
*/
|
||||
function radio_update_state( array $patch, $user_id = 0 ) {
|
||||
$user_id = $user_id ? (int) $user_id : get_current_user_id();
|
||||
if ( ! $user_id ) { return radio_default_state(); }
|
||||
|
||||
$state = radio_get_state( $user_id );
|
||||
$allowed = array_keys( radio_default_state() );
|
||||
$filtered = array_intersect_key( $patch, array_flip( $allowed ) );
|
||||
|
||||
if ( isset( $filtered['station_id'] ) ) {
|
||||
$filtered['station_id'] = sanitize_key( $filtered['station_id'] );
|
||||
if ( ! radio_find_station( $filtered['station_id'] ) ) {
|
||||
unset( $filtered['station_id'] );
|
||||
}
|
||||
}
|
||||
if ( isset( $filtered['volume'] ) ) {
|
||||
$filtered['volume'] = max( 0.0, min( 1.0, (float) $filtered['volume'] ) );
|
||||
}
|
||||
if ( isset( $filtered['theme'] ) ) {
|
||||
$filtered['theme'] = in_array( $filtered['theme'], array( 'auto', 'light', 'dark' ), true ) ? $filtered['theme'] : 'auto';
|
||||
}
|
||||
|
||||
$new_state = array_merge( $state, $filtered );
|
||||
$new_state['last_played_at'] = time();
|
||||
|
||||
update_user_meta( $user_id, RADIO_META_KEY, $new_state );
|
||||
return $new_state;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* SomaFM station list — 44 stations grouped into 10 genres.
|
||||
*
|
||||
* Ported verbatim from the RangerPlex Ranger Radio app. URLs use the
|
||||
* ice1.somafm.com 128kbps MP3 stream endpoints; SomaFM has had CORS
|
||||
* headers for years so the browser can hit these directly.
|
||||
*
|
||||
* Genre ordering follows the original RangerPlex grouping:
|
||||
* Ambient → Electronic → Lounge → Rock → Metal → Jazz → World →
|
||||
* Reggae → Holiday → Specials.
|
||||
*
|
||||
* If SomaFM ever rotates a stream URL, update it here.
|
||||
* Reference: https://somafm.com/listen/
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
/**
|
||||
* Return all stations as an ordered array, each entry containing:
|
||||
* id, name, url, genre, description.
|
||||
*
|
||||
* @return array[]
|
||||
*/
|
||||
function radio_get_stations_flat() {
|
||||
return array(
|
||||
// Ambient / Focus
|
||||
array( 'id' => 'soma-groovesalad', 'name' => 'Groove Salad', 'url' => 'https://ice1.somafm.com/groovesalad-128-mp3', 'genre' => 'Ambient', 'description' => 'Ambient/downtempo beats and grooves' ),
|
||||
array( 'id' => 'soma-dronezone', 'name' => 'Drone Zone', 'url' => 'https://ice1.somafm.com/dronezone-128-mp3', 'genre' => 'Ambient', 'description' => 'Atmospheric textures with minimal beats' ),
|
||||
array( 'id' => 'soma-deepspaceone', 'name' => 'Deep Space One', 'url' => 'https://ice1.somafm.com/deepspaceone-128-mp3', 'genre' => 'Ambient', 'description' => 'Deep ambient electronic and space music' ),
|
||||
array( 'id' => 'soma-groovesalad-classic', 'name' => 'Groove Salad Classic', 'url' => 'https://ice1.somafm.com/gsclassic-128-mp3', 'genre' => 'Ambient', 'description' => 'Classic early 2000s ambient/downtempo' ),
|
||||
array( 'id' => 'soma-spacestation', 'name' => 'Space Station Soma', 'url' => 'https://ice1.somafm.com/spacestation-128-mp3', 'genre' => 'Ambient', 'description' => 'Spaced-out ambient and mid-tempo electronica' ),
|
||||
array( 'id' => 'soma-synphaera', 'name' => 'Synphaera Radio', 'url' => 'https://ice1.somafm.com/synphaera-128-mp3', 'genre' => 'Ambient', 'description' => 'Modern electronic ambient and space music' ),
|
||||
array( 'id' => 'soma-darkzone', 'name' => 'The Dark Zone', 'url' => 'https://ice1.somafm.com/darkzone-128-mp3', 'genre' => 'Ambient', 'description' => 'The darker side of deep ambient' ),
|
||||
array( 'id' => 'soma-missioncontrol', 'name' => 'Mission Control', 'url' => 'https://ice1.somafm.com/missioncontrol-128-mp3','genre'=> 'Ambient', 'description' => 'Celebrating NASA and space explorers' ),
|
||||
|
||||
// Electronic / Coding
|
||||
array( 'id' => 'soma-defcon', 'name' => 'DEF CON Radio', 'url' => 'https://ice1.somafm.com/defcon-128-mp3', 'genre' => 'Electronic', 'description' => 'Music for hacking and coding' ),
|
||||
array( 'id' => 'soma-beatblender', 'name' => 'Beat Blender', 'url' => 'https://ice1.somafm.com/beatblender-128-mp3', 'genre' => 'Electronic', 'description' => 'Deep-house and downtempo chill' ),
|
||||
array( 'id' => 'soma-cliqhop', 'name' => 'cliqhop idm', 'url' => 'https://ice1.somafm.com/cliqhop-128-mp3', 'genre' => 'Electronic', 'description' => 'Intelligent Dance Music (IDM)' ),
|
||||
array( 'id' => 'soma-thetrip', 'name' => 'The Trip', 'url' => 'https://ice1.somafm.com/thetrip-128-mp3', 'genre' => 'Electronic', 'description' => 'Progressive house and trance' ),
|
||||
array( 'id' => 'soma-poptron', 'name' => 'PopTron', 'url' => 'https://ice1.somafm.com/poptron-128-mp3', 'genre' => 'Electronic', 'description' => 'Electropop and indie dance rock' ),
|
||||
array( 'id' => 'soma-fluid', 'name' => 'Fluid', 'url' => 'https://ice1.somafm.com/fluid-128-mp3', 'genre' => 'Electronic', 'description' => 'Instrumental hiphop and liquid trap' ),
|
||||
array( 'id' => 'soma-vaporwaves', 'name' => 'Vaporwaves', 'url' => 'https://ice1.somafm.com/vaporwaves-128-mp3', 'genre' => 'Electronic', 'description' => 'All Vaporwave, all the time' ),
|
||||
array( 'id' => 'soma-digitalis', 'name' => 'Digitalis', 'url' => 'https://ice1.somafm.com/digitalis-128-mp3', 'genre' => 'Electronic', 'description' => 'Digitally affected analog rock' ),
|
||||
array( 'id' => 'soma-dubstep', 'name' => 'Dub Step Beyond', 'url' => 'https://ice1.somafm.com/dubstep-128-mp3', 'genre' => 'Electronic', 'description' => 'Dubstep, dub and deep bass' ),
|
||||
|
||||
// Lounge / Chill
|
||||
array( 'id' => 'soma-lush', 'name' => 'Lush', 'url' => 'https://ice1.somafm.com/lush-128-mp3', 'genre' => 'Lounge', 'description' => 'Sensuous mellow female vocals' ),
|
||||
array( 'id' => 'soma-secretagent', 'name' => 'Secret Agent', 'url' => 'https://ice1.somafm.com/secretagent-128-mp3', 'genre' => 'Lounge', 'description' => 'Soundtrack for your mysterious life' ),
|
||||
array( 'id' => 'soma-illinoisstreet', 'name' => 'Illinois Street Lounge', 'url' => 'https://ice1.somafm.com/illstreet-128-mp3', 'genre' => 'Lounge', 'description' => 'Classic bachelor pad and exotica' ),
|
||||
array( 'id' => 'soma-bossabeyond', 'name' => 'Bossa Beyond', 'url' => 'https://ice1.somafm.com/bossa-128-mp3', 'genre' => 'Lounge', 'description' => 'Brazilian Bossa Nova and Samba' ),
|
||||
|
||||
// Rock / Alternative
|
||||
array( 'id' => 'soma-indiepop', 'name' => 'Indie Pop Rocks!', 'url' => 'https://ice1.somafm.com/indiepop-128-mp3', 'genre' => 'Rock', 'description' => 'Favorite indie pop tracks' ),
|
||||
array( 'id' => 'soma-u80s', 'name' => 'Underground 80s', 'url' => 'https://ice1.somafm.com/u80s-128-mp3', 'genre' => 'Rock', 'description' => 'Early 80s UK Synthpop and New Wave' ),
|
||||
array( 'id' => 'soma-seventies', 'name' => 'Left Coast 70s', 'url' => 'https://ice1.somafm.com/seventies-128-mp3', 'genre' => 'Rock', 'description' => 'Mellow album rock from the 70s' ),
|
||||
array( 'id' => 'soma-folkforward', 'name' => 'Folk Forward', 'url' => 'https://ice1.somafm.com/folkfwd-128-mp3', 'genre' => 'Rock', 'description' => 'Indie Folk and Alt-folk' ),
|
||||
array( 'id' => 'soma-bootliquor', 'name' => 'Boot Liquor', 'url' => 'https://ice1.somafm.com/bootliquor-128-mp3', 'genre' => 'Rock', 'description' => 'Americana Roots music' ),
|
||||
|
||||
// Metal / Heavy
|
||||
array( 'id' => 'soma-metal', 'name' => 'Metal Detector', 'url' => 'https://ice1.somafm.com/metal-128-mp3', 'genre' => 'Metal', 'description' => 'From black to doom, thrash to post' ),
|
||||
array( 'id' => 'soma-doomed', 'name' => 'Doomed', 'url' => 'https://ice1.somafm.com/doomed-128-mp3', 'genre' => 'Metal', 'description' => 'Dark industrial/ambient music' ),
|
||||
|
||||
// Jazz / Soul
|
||||
array( 'id' => 'soma-sonicuniverse', 'name' => 'Sonic Universe', 'url' => 'https://ice1.somafm.com/sonicuniverse-128-mp3','genre' => 'Jazz', 'description' => 'Eclectic avant-garde jazz' ),
|
||||
array( 'id' => 'soma-7soul', 'name' => 'Seven Inch Soul', 'url' => 'https://ice1.somafm.com/7soul-128-mp3', 'genre' => 'Jazz', 'description' => 'Vintage soul from vinyl 45s' ),
|
||||
|
||||
// World / International
|
||||
array( 'id' => 'soma-thistle', 'name' => 'ThistleRadio', 'url' => 'https://ice1.somafm.com/thistle-128-mp3', 'genre' => 'World', 'description' => 'Celtic roots and branches' ),
|
||||
array( 'id' => 'soma-suburbsofgoa', 'name' => 'Suburbs of Goa', 'url' => 'https://ice1.somafm.com/suburbsofgoa-128-mp3', 'genre' => 'World', 'description' => 'Asian world beats and beyond' ),
|
||||
array( 'id' => 'soma-tikitime', 'name' => 'Tiki Time', 'url' => 'https://ice1.somafm.com/tikitime-128-mp3', 'genre' => 'World', 'description' => 'Classic Tiki and island rhythms' ),
|
||||
|
||||
// Reggae
|
||||
array( 'id' => 'soma-reggae', 'name' => 'Heavyweight Reggae', 'url' => 'https://ice1.somafm.com/reggae-128-mp3', 'genre' => 'Reggae', 'description' => 'Reggae, Ska, Rocksteady classics' ),
|
||||
|
||||
// Holiday (seasonal)
|
||||
array( 'id' => 'soma-xmaslounge', 'name' => 'Christmas Lounge', 'url' => 'https://ice1.somafm.com/christmas-128-mp3', 'genre' => 'Holiday', 'description' => 'Chilled holiday grooves' ),
|
||||
array( 'id' => 'soma-xmasrocks', 'name' => 'Christmas Rocks!', 'url' => 'https://ice1.somafm.com/xmasrocks-128-mp3', 'genre' => 'Holiday', 'description' => 'Indie/alternative holiday season' ),
|
||||
array( 'id' => 'soma-xmasinfrisko', 'name' => 'Xmas in Frisko', 'url' => 'https://ice1.somafm.com/xmasinfrisko-128-mp3', 'genre' => 'Holiday', 'description' => 'Wacky eclectic holiday mix' ),
|
||||
array( 'id' => 'soma-jollysoul', 'name' => "Jolly Ol' Soul", 'url' => 'https://ice1.somafm.com/jollysoul-128-mp3', 'genre' => 'Holiday', 'description' => 'Soul of the season' ),
|
||||
array( 'id' => 'soma-deptstore', 'name' => 'Department Store Christmas','url' => 'https://ice1.somafm.com/deptstore-128-mp3', 'genre' => 'Holiday', 'description' => 'Holiday elevator music' ),
|
||||
|
||||
// Specials / Eclectic
|
||||
array( 'id' => 'soma-blackrock', 'name' => 'Black Rock FM', 'url' => 'https://ice1.somafm.com/brfm-128-mp3', 'genre' => 'Specials', 'description' => 'From Burning Man festival' ),
|
||||
array( 'id' => 'soma-covers', 'name' => 'Covers', 'url' => 'https://ice1.somafm.com/covers-128-mp3', 'genre' => 'Specials', 'description' => 'Songs you know by artists you don\'t' ),
|
||||
array( 'id' => 'soma-sf1033', 'name' => 'SF 10-33', 'url' => 'https://ice1.somafm.com/sf1033-128-mp3', 'genre' => 'Specials', 'description' => 'Ambient + SF public safety radio' ),
|
||||
array( 'id' => 'soma-n5md', 'name' => 'n5MD Radio', 'url' => 'https://ice1.somafm.com/n5md-128-mp3', 'genre' => 'Specials', 'description' => 'Emotional experiments in music' ),
|
||||
array( 'id' => 'soma-insound', 'name' => 'The In-Sound', 'url' => 'https://ice1.somafm.com/insound-128-mp3', 'genre' => 'Specials', 'description' => '60s/70s Hipster Euro Pop' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return stations grouped by genre, in genre-display order.
|
||||
*
|
||||
* @return array Map of genre => array of station entries.
|
||||
*/
|
||||
function radio_get_stations_grouped() {
|
||||
$genre_order = array( 'Ambient', 'Electronic', 'Lounge', 'Rock', 'Metal', 'Jazz', 'World', 'Reggae', 'Holiday', 'Specials' );
|
||||
$grouped = array_fill_keys( $genre_order, array() );
|
||||
foreach ( radio_get_stations_flat() as $station ) {
|
||||
$genre = isset( $station['genre'] ) ? $station['genre'] : 'Specials';
|
||||
if ( ! isset( $grouped[ $genre ] ) ) {
|
||||
$grouped[ $genre ] = array();
|
||||
}
|
||||
$grouped[ $genre ][] = $station;
|
||||
}
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a station by its id.
|
||||
*
|
||||
* @param string $station_id
|
||||
* @return array|null Station entry or null if not found.
|
||||
*/
|
||||
function radio_find_station( $station_id ) {
|
||||
$station_id = sanitize_key( $station_id );
|
||||
foreach ( radio_get_stations_flat() as $s ) {
|
||||
if ( $s['id'] === $station_id ) { return $s; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default station — the one new users land on. Groove Salad is the
|
||||
* most popular SomaFM channel and a safe ambient/coding default.
|
||||
*/
|
||||
function radio_default_station_id() {
|
||||
return 'soma-groovesalad';
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
/**
|
||||
* Radio — self-hosted update checker against the Gitea repo.
|
||||
*
|
||||
* Direct port of the Buddy / Logbook updater (proven in production).
|
||||
* Polls Gitea's /releases/latest, falls back to /tags?limit=1 when no
|
||||
* formal Release object exists, compares against RADIO_VERSION,
|
||||
* renders an Updates panel on the Settings page. Cached 12h on
|
||||
* success, 1h on negative responses.
|
||||
*
|
||||
* Repo coordinates are constants you can override via define() in
|
||||
* wp-config.php if the repo ever moves.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
if ( ! defined( 'RADIO_GITEA_HOST' ) ) { define( 'RADIO_GITEA_HOST', 'https://git.davidtkeane.com' ); }
|
||||
if ( ! defined( 'RADIO_GITEA_OWNER' ) ) { define( 'RADIO_GITEA_OWNER', 'ranger' ); }
|
||||
if ( ! defined( 'RADIO_GITEA_REPO' ) ) { define( 'RADIO_GITEA_REPO', 'a-radio' ); }
|
||||
|
||||
function radio_gitea_repo_url() {
|
||||
return RADIO_GITEA_HOST . '/' . RADIO_GITEA_OWNER . '/' . RADIO_GITEA_REPO;
|
||||
}
|
||||
function radio_gitea_releases_url() {
|
||||
return radio_gitea_repo_url() . '/releases';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release/tag, normalised. Returns null on hard
|
||||
* error, or an array including `version`.
|
||||
*/
|
||||
function radio_fetch_latest_release( $force_refresh = false ) {
|
||||
$cache_key = 'radio_gitea_latest';
|
||||
|
||||
if ( ! $force_refresh ) {
|
||||
$cached = get_site_transient( $cache_key );
|
||||
if ( is_array( $cached ) ) { return $cached; }
|
||||
}
|
||||
|
||||
$base_api = RADIO_GITEA_HOST . '/api/v1/repos/' . RADIO_GITEA_OWNER . '/' . RADIO_GITEA_REPO;
|
||||
|
||||
// Try formal Release first.
|
||||
$response = wp_remote_get( $base_api . '/releases/latest', array( 'timeout' => 8 ) );
|
||||
if ( is_wp_error( $response ) ) { return null; }
|
||||
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$body = ( $code === 200 ) ? json_decode( wp_remote_retrieve_body( $response ), true ) : null;
|
||||
|
||||
// Fallback to /tags if no Release object exists yet.
|
||||
if ( $code !== 200 || ! is_array( $body ) || empty( $body['tag_name'] ) ) {
|
||||
$tags_response = wp_remote_get( $base_api . '/tags?limit=1', array( 'timeout' => 8 ) );
|
||||
if ( ! is_wp_error( $tags_response )
|
||||
&& (int) wp_remote_retrieve_response_code( $tags_response ) === 200 ) {
|
||||
$tags = json_decode( wp_remote_retrieve_body( $tags_response ), true );
|
||||
if ( is_array( $tags ) && ! empty( $tags[0]['name'] ) ) {
|
||||
$body = array(
|
||||
'tag_name' => $tags[0]['name'],
|
||||
'html_url' => radio_gitea_repo_url() . '/src/tag/' . rawurlencode( $tags[0]['name'] ),
|
||||
'body' => isset( $tags[0]['message'] ) ? $tags[0]['message'] : '',
|
||||
'published_at' => isset( $tags[0]['commit']['created'] ) ? $tags[0]['commit']['created'] : null,
|
||||
'assets' => array(),
|
||||
);
|
||||
$code = 200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $code !== 200 || ! is_array( $body ) || empty( $body['tag_name'] ) ) {
|
||||
$info = array(
|
||||
'version' => null,
|
||||
'html_url' => radio_gitea_releases_url(),
|
||||
'download_url' => null,
|
||||
'body' => '',
|
||||
'published_at' => null,
|
||||
'error_code' => $code,
|
||||
);
|
||||
set_site_transient( $cache_key, $info, HOUR_IN_SECONDS );
|
||||
return $info;
|
||||
}
|
||||
|
||||
$version = ltrim( (string) $body['tag_name'], 'vV' );
|
||||
|
||||
// Prefer a .zip asset; fall back to Gitea source-archive URL.
|
||||
$download_url = null;
|
||||
if ( ! empty( $body['assets'] ) && is_array( $body['assets'] ) ) {
|
||||
foreach ( $body['assets'] as $asset ) {
|
||||
if ( isset( $asset['name'], $asset['browser_download_url'] )
|
||||
&& substr( strtolower( $asset['name'] ), -4 ) === '.zip' ) {
|
||||
$download_url = $asset['browser_download_url'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( ! $download_url ) {
|
||||
$download_url = radio_gitea_repo_url() . '/archive/' . rawurlencode( $body['tag_name'] ) . '.zip';
|
||||
}
|
||||
|
||||
$info = array(
|
||||
'version' => $version,
|
||||
'html_url' => isset( $body['html_url'] ) ? esc_url_raw( $body['html_url'] ) : '',
|
||||
'download_url' => esc_url_raw( $download_url ),
|
||||
'body' => isset( $body['body'] ) ? wp_strip_all_tags( $body['body'] ) : '',
|
||||
'published_at' => isset( $body['published_at'] ) ? $body['published_at'] : null,
|
||||
);
|
||||
|
||||
set_site_transient( $cache_key, $info, 12 * HOUR_IN_SECONDS );
|
||||
return $info;
|
||||
}
|
||||
|
||||
function radio_update_status( $force_refresh = false ) {
|
||||
$current = defined( 'RADIO_VERSION' ) ? RADIO_VERSION : '0.0.0';
|
||||
$latest = radio_fetch_latest_release( $force_refresh );
|
||||
|
||||
if ( ! $latest || empty( $latest['version'] ) ) {
|
||||
$msg = __( 'No releases tagged on the Gitea repo yet.', 'radio' );
|
||||
if ( $latest && ! empty( $latest['error_code'] ) && (int) $latest['error_code'] !== 404 ) {
|
||||
$msg = sprintf( __( 'Could not reach Gitea (HTTP %d). Try again in a few minutes.', 'radio' ), (int) $latest['error_code'] );
|
||||
}
|
||||
return array(
|
||||
'status' => 'unknown',
|
||||
'current' => $current,
|
||||
'message' => $msg,
|
||||
'repo_url' => radio_gitea_repo_url(),
|
||||
);
|
||||
}
|
||||
|
||||
if ( version_compare( $latest['version'], $current, '>' ) ) {
|
||||
return array(
|
||||
'status' => 'available',
|
||||
'current' => $current,
|
||||
'latest' => $latest['version'],
|
||||
'html_url' => $latest['html_url'],
|
||||
'download_url' => $latest['download_url'],
|
||||
'published_at' => $latest['published_at'],
|
||||
'body' => $latest['body'],
|
||||
'message' => sprintf( __( 'A new version (v%1$s) is available — you are on v%2$s.', 'radio' ), $latest['version'], $current ),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => 'up-to-date',
|
||||
'current' => $current,
|
||||
'latest' => $latest['version'],
|
||||
'message' => sprintf( __( 'You are up to date (v%s).', 'radio' ), $current ),
|
||||
'repo_url' => radio_gitea_repo_url(),
|
||||
);
|
||||
}
|
||||
|
||||
add_action( 'wp_ajax_radio_check_updates', 'radio_ajax_check_updates' );
|
||||
function radio_ajax_check_updates() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Insufficient permissions.', 403 );
|
||||
}
|
||||
check_ajax_referer( 'radio_check_updates', 'nonce' );
|
||||
delete_site_transient( 'radio_gitea_latest' );
|
||||
wp_send_json_success( radio_update_status( true ) );
|
||||
}
|
||||
|
||||
function radio_render_updates_panel() {
|
||||
$status = radio_update_status( false );
|
||||
$nonce = wp_create_nonce( 'radio_check_updates' );
|
||||
$repo_url = radio_gitea_repo_url();
|
||||
$rel_url = radio_gitea_releases_url();
|
||||
?>
|
||||
<div class="radio-updates" style="max-width:720px; margin-top:24px; padding:18px 20px; background:#fff; border:1px solid #ccd0d4; border-radius:4px;">
|
||||
<h2 style="margin-top:0;"><?php esc_html_e( 'Updates', 'radio' ); ?></h2>
|
||||
<p style="margin:0 0 12px;">
|
||||
<?php esc_html_e( 'Radio is self-hosted on Gitea. Click Check now to ask the repo whether there is a newer release than the one you are running.', 'radio' ); ?>
|
||||
</p>
|
||||
|
||||
<p id="radio-update-status" style="margin:0 0 12px;">
|
||||
<strong><?php esc_html_e( 'Status:', 'radio' ); ?></strong>
|
||||
<span id="radio-update-status-text"><?php echo esc_html( $status['message'] ); ?></span>
|
||||
<?php if ( $status['status'] === 'available' && ! empty( $status['download_url'] ) ) : ?>
|
||||
<br>
|
||||
<a href="<?php echo esc_url( $status['download_url'] ); ?>" class="button button-primary" style="margin-top:8px;">
|
||||
<?php
|
||||
/* translators: %s is the latest version number, e.g. "0.2.0" */
|
||||
echo esc_html( sprintf( __( 'Download v%s (.zip)', 'radio' ), $status['latest'] ) );
|
||||
?>
|
||||
</a>
|
||||
<?php if ( ! empty( $status['html_url'] ) ) : ?>
|
||||
<a href="<?php echo esc_url( $status['html_url'] ); ?>" target="_blank" rel="noopener" style="margin-left:8px;"><?php esc_html_e( 'View release notes →', 'radio' ); ?></a>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 4px;">
|
||||
<button type="button" id="radio-check-updates-btn" class="button" data-nonce="<?php echo esc_attr( $nonce ); ?>">
|
||||
↻ <?php esc_html_e( 'Check now', 'radio' ); ?>
|
||||
</button>
|
||||
<a href="<?php echo esc_url( $repo_url ); ?>" target="_blank" rel="noopener" class="button" style="margin-left:6px;"><?php esc_html_e( 'View on Gitea', 'radio' ); ?></a>
|
||||
<a href="<?php echo esc_url( $rel_url ); ?>" target="_blank" rel="noopener" class="button" style="margin-left:6px;"><?php esc_html_e( 'View all releases', 'radio' ); ?></a>
|
||||
</p>
|
||||
<p style="margin:10px 0 0; color:#646970; font-size:12px;">
|
||||
<?php esc_html_e( 'Manual update path: download the .zip, deactivate the plugin in WordPress, upload via Plugins → Add New → Upload, reactivate. Your settings survive the upgrade (state is stored in user_meta).', 'radio' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('radio-check-updates-btn');
|
||||
var statusText = document.getElementById('radio-update-status-text');
|
||||
if (!btn || !statusText) { return; }
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
var nonce = btn.getAttribute('data-nonce');
|
||||
btn.disabled = true;
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = '↻ Checking…';
|
||||
statusText.textContent = 'Asking Gitea…';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('action', 'radio_check_updates');
|
||||
fd.append('nonce', nonce);
|
||||
|
||||
fetch(ajaxurl, { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res || !res.success) {
|
||||
statusText.textContent = (res && res.data) ? String(res.data) : 'Check failed.';
|
||||
} else {
|
||||
statusText.textContent = res.data.message || 'Check complete.';
|
||||
if (res.data.status === 'available' && res.data.download_url) {
|
||||
setTimeout(function () { window.location.reload(); }, 400);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () { statusText.textContent = 'Network error — try again in a moment.'; })
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = orig;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* Radio — a free SomaFM player for your WordPress dashboard
|
||||
*
|
||||
* Plugin Name: Radio
|
||||
* Plugin URI: https://icanhelp.ie/radio
|
||||
* Description: A small, focused, free radio player for your WordPress admin. 44 SomaFM stations grouped by 10 genres — ambient, electronic, lounge, rock, metal, jazz, world, reggae, holiday, specials. Plays via HTML5 audio; volume + station choice persist per-user.
|
||||
* Version: 0.1.0
|
||||
* Requires at least: 5.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: David Keane
|
||||
* Author URI: https://rangersmyth.xyz/
|
||||
* License: GPL v2 or later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
* Text Domain: radio
|
||||
*
|
||||
* @package Radio
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
||||
|
||||
// Plugin coordinates.
|
||||
if ( ! defined( 'RADIO_VERSION' ) ) { define( 'RADIO_VERSION', '0.1.0' ); }
|
||||
if ( ! defined( 'RADIO_FILE' ) ) { define( 'RADIO_FILE', __FILE__ ); }
|
||||
if ( ! defined( 'RADIO_PATH' ) ) { define( 'RADIO_PATH', plugin_dir_path( __FILE__ ) ); }
|
||||
if ( ! defined( 'RADIO_URL' ) ) { define( 'RADIO_URL', plugin_dir_url( __FILE__ ) ); }
|
||||
if ( ! defined( 'RADIO_BASENAME' ) ) { define( 'RADIO_BASENAME', plugin_basename( __FILE__ ) ); }
|
||||
|
||||
// Includes — each file owns one concern.
|
||||
require_once RADIO_PATH . 'inc/stations.php'; // the 44-station array + genre helpers
|
||||
require_once RADIO_PATH . 'inc/state.php'; // user_meta storage for station/volume choice
|
||||
require_once RADIO_PATH . 'inc/dashboard-widget.php'; // the compact mini-player on WP Dashboard
|
||||
require_once RADIO_PATH . 'inc/admin-page.php'; // dedicated Radio admin page (larger player)
|
||||
require_once RADIO_PATH . 'inc/about.php'; // About page
|
||||
require_once RADIO_PATH . 'inc/settings.php'; // Settings page
|
||||
require_once RADIO_PATH . 'inc/updater.php'; // self-hosted update checker against Gitea
|
||||
|
||||
/**
|
||||
* Admin menu registration. Radio gets its own top-level menu — having
|
||||
* the player one click away matters more than tucking it into Tools or
|
||||
* Settings. Icon is dashicons-format-audio for direct visual match.
|
||||
*/
|
||||
add_action( 'admin_menu', 'radio_register_admin_menu' );
|
||||
function radio_register_admin_menu() {
|
||||
add_menu_page(
|
||||
__( 'Radio', 'radio' ),
|
||||
__( 'Radio', 'radio' ),
|
||||
'read', // any logged-in user with read access has their own radio
|
||||
'radio',
|
||||
'radio_render_main_page',
|
||||
'dashicons-format-audio',
|
||||
72 // sits below Comments, above plugin entries
|
||||
);
|
||||
|
||||
// "My Radio" — main landing submenu. Same slug as parent so clicking
|
||||
// either entry lands on the same page. Empty callback so only the
|
||||
// parent renderer fires (lesson learned from Buddy / Logbook duplicate-
|
||||
// form gotcha).
|
||||
add_submenu_page(
|
||||
'radio',
|
||||
__( 'My Radio', 'radio' ),
|
||||
__( 'My Radio', 'radio' ),
|
||||
'read',
|
||||
'radio',
|
||||
''
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'radio',
|
||||
__( 'Settings', 'radio' ),
|
||||
__( 'Settings', 'radio' ),
|
||||
'manage_options',
|
||||
'radio-settings',
|
||||
'radio_render_settings_page'
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'radio',
|
||||
__( 'About', 'radio' ),
|
||||
__( 'About', 'radio' ),
|
||||
'read',
|
||||
'radio-about',
|
||||
'radio_render_about_page'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue Radio's CSS + JS on its own admin pages and on the main
|
||||
* dashboard (where the mini-player widget lives).
|
||||
*/
|
||||
add_action( 'admin_enqueue_scripts', 'radio_enqueue_admin_assets' );
|
||||
function radio_enqueue_admin_assets( $hook ) {
|
||||
$radio_hooks = array(
|
||||
'index.php', // WP Dashboard (the widget lives here)
|
||||
'toplevel_page_radio', // Radio main page
|
||||
'radio_page_radio-settings', // Settings
|
||||
'radio_page_radio-about', // About
|
||||
);
|
||||
if ( ! in_array( $hook, $radio_hooks, true ) ) { return; }
|
||||
|
||||
wp_enqueue_style(
|
||||
'radio-admin',
|
||||
RADIO_URL . 'assets/css/radio.css',
|
||||
array(),
|
||||
RADIO_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'radio-admin',
|
||||
RADIO_URL . 'assets/js/radio.js',
|
||||
array(),
|
||||
RADIO_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
// Pass per-user state + station list + AJAX URL to JS.
|
||||
wp_localize_script( 'radio-admin', 'RadioPlugin', array(
|
||||
'state' => radio_get_state(),
|
||||
'stations' => radio_get_stations_flat(),
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'radio_save_state' ),
|
||||
'strings' => array(
|
||||
'play' => __( 'Play', 'radio' ),
|
||||
'pause' => __( 'Pause', 'radio' ),
|
||||
'loading' => __( 'Loading…', 'radio' ),
|
||||
'error' => __( 'Stream error — try another station.', 'radio' ),
|
||||
'nowPlaying' => __( 'Now Playing', 'radio' ),
|
||||
'volume' => __( 'Volume', 'radio' ),
|
||||
'station' => __( 'Station', 'radio' ),
|
||||
),
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint for saving per-user state (station / volume changes).
|
||||
* Avoids a full page reload on every interaction.
|
||||
*/
|
||||
add_action( 'wp_ajax_radio_save_state', 'radio_ajax_save_state' );
|
||||
function radio_ajax_save_state() {
|
||||
if ( ! is_user_logged_in() ) {
|
||||
wp_send_json_error( 'Not logged in.', 401 );
|
||||
}
|
||||
check_ajax_referer( 'radio_save_state', 'nonce' );
|
||||
|
||||
$patch = array();
|
||||
if ( isset( $_POST['station_id'] ) ) {
|
||||
$patch['station_id'] = sanitize_key( wp_unslash( $_POST['station_id'] ) );
|
||||
}
|
||||
if ( isset( $_POST['volume'] ) ) {
|
||||
$patch['volume'] = max( 0.0, min( 1.0, (float) $_POST['volume'] ) );
|
||||
}
|
||||
|
||||
if ( empty( $patch ) ) {
|
||||
wp_send_json_error( 'Nothing to save.', 400 );
|
||||
}
|
||||
|
||||
radio_update_state( $patch );
|
||||
wp_send_json_success( radio_get_state() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Activation hook: ensure the version option is set so the updater can
|
||||
* track it.
|
||||
*/
|
||||
register_activation_hook( __FILE__, 'radio_on_activate' );
|
||||
function radio_on_activate() {
|
||||
if ( false === get_option( 'radio_version' ) ) {
|
||||
add_option( 'radio_version', RADIO_VERSION );
|
||||
} else {
|
||||
update_option( 'radio_version', RADIO_VERSION );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user