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:
2026-05-26 09:40:23 +01:00
commit a22ddfb6d3
13 changed files with 1543 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.DS_Store
Thumbs.db
*.log
*.bak
.idea/
.vscode/
node_modules/
+47
View File
@@ -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.
+36
View File
@@ -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/)
+228
View File
@@ -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;
}
+194
View File
@@ -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();
}
})();
+84
View File
@@ -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> &mdash; 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
}
+91
View File
@@ -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
}
+88
View File
@@ -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
}
+130
View File
@@ -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
}
+97
View File
@@ -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;
}
+131
View File
@@ -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
View File
@@ -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
}
+172
View File
@@ -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 );
}
}