Files
rangerhq-radio/radio.php
T
ranger 8b531910f6 v1.0.0 — Stability milestone
No functional change from 0.7.6. Version bump signals that the
public API (wp_usermeta storage shape, 4-button search URL
templates, stations registry shape, dedup + cap + (unknown) skip
rules) is locked. Breaking changes from here go in 2.0.0.

Five days live on wp.org, 50+ downloads, pattern ported to Tuner
(Chrome Web Store live the same day) and Buddy (wp.org r2 review).
The architecture has been validated across two marketplaces and
three family siblings. 0.x semantics no longer accurate.

Same day: Tuner v0.3.0 LIVE on Chrome Web Store ~15.5h after
submission. Radio v1.0.0 ships as the foundation that proved the
pattern.
2026-06-09 21:38:35 +01:00

388 lines
18 KiB
PHP

<?php
/**
* RangerHQ Radio — a small, focused internet radio player for your WP dashboard.
*
* Plugin Name: RangerHQ Radio
* Plugin URI: https://davidtkeane.com/rangerhq-radio
* Description: A small, focused internet radio player for your WordPress admin. 44 hand-curated stations from SomaFM across 10 genres — ambient, electronic, lounge, rock, metal, jazz, world, reggae, holiday, specials. Plays via HTML5 audio; per-user station + volume + history + favourites; pop-out window for continuous background play.
* Version: 1.0.0
* Requires at least: 5.3
* Requires PHP: 7.4
* Author: David Keane
* Author URI: https://davidtkeane.com
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: rangerhq-radio
*
* @package RangerHQ_Radio
*
* Copyright (C) 2026 David Keane
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version. See LICENSE for the full text.
*/
if ( ! defined( 'ABSPATH' ) ) { exit; }
// Plugin coordinates.
if ( ! defined( 'RADIO_VERSION' ) ) { define( 'RADIO_VERSION', '1.0.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__ ) ); }
if ( ! defined( 'RADIO_GITEA_URL' ) ) { define( 'RADIO_GITEA_URL', 'https://git.davidtkeane.com/ranger/rangerhq-radio' ); }
if ( ! defined( 'RADIO_SUPPORT_URL' ) ) { define( 'RADIO_SUPPORT_URL', 'https://buymeacoffee.com/davidtkeane' ); }
// 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/history.php'; // Track history + favourites (v0.5.0)
/**
* 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', 'rangerhq-radio' ),
__( 'Radio', 'rangerhq-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', 'rangerhq-radio' ),
__( 'My Radio', 'rangerhq-radio' ),
'read',
'radio',
''
);
add_submenu_page(
'radio',
__( 'Settings', 'rangerhq-radio' ),
__( 'Settings', 'rangerhq-radio' ),
'manage_options',
'radio-settings',
'radio_render_settings_page'
);
add_submenu_page(
'radio',
__( 'Track history', 'rangerhq-radio' ),
__( 'History', 'rangerhq-radio' ),
'read',
'radio-history',
'radio_render_history_page'
);
add_submenu_page(
'radio',
__( 'About', 'rangerhq-radio' ),
__( 'About', 'rangerhq-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
'radio_page_radio-history', // History + Favourites (v0.5.0)
);
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' ),
'popoutUrl' => admin_url( 'admin-post.php?action=radio_popout' ),
'strings' => array(
'play' => __( 'Play', 'rangerhq-radio' ),
'pause' => __( 'Pause', 'rangerhq-radio' ),
'loading' => __( 'Loading…', 'rangerhq-radio' ),
'error' => __( 'Stream error — try another station.', 'rangerhq-radio' ),
'saveError' => __( 'Preferences not saved — check your connection.', 'rangerhq-radio' ),
'mute' => __( 'Mute', 'rangerhq-radio' ),
'unmute' => __( 'Unmute', 'rangerhq-radio' ),
'nowPlaying' => __( 'Now Playing', 'rangerhq-radio' ),
'volume' => __( 'Volume', 'rangerhq-radio' ),
'station' => __( 'Station', 'rangerhq-radio' ),
'addFav' => __( 'Add to favourites', 'rangerhq-radio' ),
'removeFav' => __( 'Remove from favourites', 'rangerhq-radio' ),
'clearConfirm' => __( 'Clear all track history? (Favourites are kept.)', 'rangerhq-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() );
}
/**
* AJAX: log a played track to the user's history (v0.5.0).
* Nonce: same `radio_save_state` token used for state saves — both come
* from the same `wp_localize_script` config available on player pages.
*/
add_action( 'wp_ajax_radio_log_track', 'radio_ajax_log_track' );
function radio_ajax_log_track() {
if ( ! is_user_logged_in() ) { wp_send_json_error( 'Not logged in.', 401 ); }
check_ajax_referer( 'radio_save_state', 'nonce' );
// Unslash + sanitize at access; radio_sanitize_entry() also re-sanitizes
// downstream as belt+braces, but PCP wants the cleanup AT the access point.
$logged = radio_log_track( array(
'artist' => isset( $_POST['artist'] ) ? sanitize_text_field( wp_unslash( $_POST['artist'] ) ) : '',
'title' => isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '',
'station' => isset( $_POST['station'] ) ? sanitize_text_field( wp_unslash( $_POST['station'] ) ) : '',
'station_id' => isset( $_POST['station_id'] ) ? sanitize_key( wp_unslash( $_POST['station_id'] ) ) : '',
) );
wp_send_json_success( array( 'logged' => (bool) $logged ) );
}
/**
* AJAX: toggle whether a track is favourited.
* Nonce: `radio_history` — created fresh per History-page render so the
* AJAX is gated to users who actually loaded the page.
*/
add_action( 'wp_ajax_radio_toggle_favourite', 'radio_ajax_toggle_favourite' );
function radio_ajax_toggle_favourite() {
if ( ! is_user_logged_in() ) { wp_send_json_error( 'Not logged in.', 401 ); }
check_ajax_referer( 'radio_history', 'nonce' );
$new_state = radio_toggle_favourite( array(
'artist' => isset( $_POST['artist'] ) ? sanitize_text_field( wp_unslash( $_POST['artist'] ) ) : '',
'title' => isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '',
'station' => isset( $_POST['station'] ) ? sanitize_text_field( wp_unslash( $_POST['station'] ) ) : '',
'station_id' => isset( $_POST['station_id'] ) ? sanitize_key( wp_unslash( $_POST['station_id'] ) ) : '',
) );
wp_send_json_success( array( 'favourite' => (bool) $new_state ) );
}
/** AJAX: clear the current user's history (favourites preserved). */
add_action( 'wp_ajax_radio_clear_history', 'radio_ajax_clear_history' );
function radio_ajax_clear_history() {
if ( ! is_user_logged_in() ) { wp_send_json_error( 'Not logged in.', 401 ); }
check_ajax_referer( 'radio_history', 'nonce' );
radio_clear_history_all();
wp_send_json_success();
}
/**
* v0.6.0 — Pop-out mini player. Renders a standalone HTML page (no WP
* admin chrome) at `admin-post.php?action=radio_popout`, opened by the
* JS via `window.open()`. The popup persists across main-tab navigation
* so background music keeps playing when the user moves around the
* admin. `&play=1` in the URL tells `radio.js` to auto-resume on load.
*/
add_action( 'admin_post_radio_popout', 'radio_render_popout_page' );
function radio_render_popout_page() {
if ( ! current_user_can( 'read' ) ) {
wp_die( esc_html__( 'You do not have permission to view this page.', 'rangerhq-radio' ) );
}
$state = radio_get_state();
$station = radio_find_station( $state['station_id'] );
$stations = radio_get_stations_grouped();
$theme = isset( $state['theme'] ) ? $state['theme'] : 'auto';
if ( ! in_array( $theme, array( 'auto', 'light', 'dark' ), true ) ) { $theme = 'auto'; }
$cfg = array(
'state' => $state,
'stations' => radio_get_stations_flat(),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'radio_save_state' ),
'popoutUrl' => '', // already in popout — no further popouts
'autoPlay' => isset( $_GET['play'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- query flag only, no state change
'strings' => array(
'play' => __( 'Play', 'rangerhq-radio' ),
'pause' => __( 'Pause', 'rangerhq-radio' ),
'loading' => __( 'Loading…', 'rangerhq-radio' ),
'error' => __( 'Stream error — try another station.', 'rangerhq-radio' ),
'saveError' => __( 'Preferences not saved — check your connection.', 'rangerhq-radio' ),
'mute' => __( 'Mute', 'rangerhq-radio' ),
'unmute' => __( 'Unmute', 'rangerhq-radio' ),
'nowPlaying' => __( 'Now Playing', 'rangerhq-radio' ),
'volume' => __( 'Volume', 'rangerhq-radio' ),
'station' => __( 'Station', 'rangerhq-radio' ),
),
);
/*
* v0.7.0: Enqueue popup assets via WP so we can emit them with
* wp_print_styles() / wp_print_footer_scripts() — passes PCP's
* NonEnqueuedStylesheet / NonEnqueuedScript checks (we used raw
* <link> / <script> tags before). dashicons is core-registered.
*/
wp_enqueue_style( 'dashicons' );
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 );
wp_localize_script( 'radio-admin', 'RadioPlugin', $cfg );
?>
<!DOCTYPE html>
<html lang="<?php echo esc_attr( str_replace( '_', '-', get_bloginfo( 'language' ) ) ); ?>">
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php
/* translators: %s = currently playing station name */
printf( esc_html__( 'Radio — %s', 'rangerhq-radio' ), esc_html( $station['name'] ) );
?></title>
<?php wp_print_styles(); /* dashicons + radio.css; popup-specific overrides scoped under body.radio-popout in radio.css */ ?>
</head>
<body class="radio-popout radio-theme-<?php echo esc_attr( $theme ); ?>">
<div class="radio-popout-header">
<h1><?php esc_html_e( 'Radio', 'rangerhq-radio' ); ?></h1>
<button type="button" class="radio-popout-close" onclick="window.close()" title="<?php esc_attr_e( 'Close', 'rangerhq-radio' ); ?>">✕</button>
</div>
<div class="radio-popout-wrap">
<div class="radio-player" data-radio-surface="popout">
<div class="radio-player__now">
<span class="radio-player__indicator" aria-hidden="true">
<span class="radio-player__bars"><span></span><span></span><span></span><span></span></span>
<canvas class="radio-player__viz" data-radio-viz hidden></canvas>
</span>
<span class="radio-player__label"><?php esc_html_e( 'Now Playing', 'rangerhq-radio' ); ?></span>
<span class="radio-player__station-name" data-radio-name><?php echo esc_html( $station['name'] ); ?></span>
<span class="radio-player__station-genre" data-radio-genre><?php echo esc_html( $station['genre'] ); ?></span>
<p class="radio-player__station-desc" data-radio-desc><?php echo esc_html( $station['description'] ); ?></p>
<p class="radio-player__track" data-radio-track hidden></p>
</div>
<div class="radio-player__controls">
<button type="button" class="button button-primary radio-player__play" data-radio-play>
<span class="radio-player__play-glyph" data-radio-play-glyph aria-hidden="true">&#9654;</span>
<span data-radio-play-label><?php esc_html_e( 'Play', 'rangerhq-radio' ); ?></span>
</button>
<div class="radio-player__volume">
<button type="button" class="radio-player__mute" data-radio-mute aria-label="<?php esc_attr_e( 'Mute', 'rangerhq-radio' ); ?>">
<span class="dashicons dashicons-controls-volumeon" aria-hidden="true"></span>
</button>
<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', 'rangerhq-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-popout"><?php esc_html_e( 'Station', 'rangerhq-radio' ); ?></label>
<select id="radio-station-popout" 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" crossorigin="anonymous"></audio>
</div>
</div>
<?php wp_print_footer_scripts(); /* radio.js + the wp_localize_script-emitted window.RadioPlugin inline blob */ ?>
</body>
</html>
<?php
exit;
}
/**
* Surface the user's theme choice (auto/light/dark) to CSS as a body
* class. `radio-theme-dark` forces dark; `radio-theme-auto` lets the
* CSS respect `prefers-color-scheme`; `radio-theme-light` is the
* default no-op.
*/
add_filter( 'admin_body_class', 'radio_admin_body_class' );
function radio_admin_body_class( $classes ) {
if ( ! is_user_logged_in() ) { return $classes; }
$state = radio_get_state();
$theme = isset( $state['theme'] ) ? $state['theme'] : 'auto';
if ( ! in_array( $theme, array( 'auto', 'light', 'dark' ), true ) ) { $theme = 'auto'; }
return $classes . ' radio-theme-' . $theme;
}
/**
* 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 );
}
}