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,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
|
||||
}
|
||||
Reference in New Issue
Block a user