/*! * RangerHQ Radio — vanilla JS audio controller. * * 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 in the plugin root * for the full text, or visit https://www.gnu.org/licenses/gpl-2.0.html * * --- module overview --- * * 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. * * v0.3.0 additions: * - Mute toggle on the speaker icon (preserves prior volume). * - MediaSession API integration — OS media keys (F8 on Mac, headphone * buttons, lock-screen widget) control the player. * - SomaFM current-track polling (every 30s while playing). Best-effort: * silently hidden if the API is unavailable / CORS-blocked. * - Save errors surface to the user as a transient notice rather than * being swallowed silently. * - Settings-page volume slider live-label is wired here (no inline * `oninput` in settings.php). * * No build step. No dependencies. */ (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; } /** Extract the SomaFM short-code from a stream URL. * e.g. https://ice1.somafm.com/groovesalad-128-mp3 → "groovesalad". * Used for the current-track JSON endpoint. */ function getSomaCode(station) { if (!station || !station.url) { return null; } var m = station.url.match(/somafm\.com\/([^/]+?)-\d+-mp3/); return m ? m[1] : null; } /** Persist a state patch back to the server. On failure, surface a * transient notice on the originating player surface (if given) so * the user knows their preference didn't save. */ function saveState(patch, surface) { 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 }).then(function (r) { if (!r.ok) { throw new Error('save failed: HTTP ' + r.status); } }).catch(function (err) { if (window.console && console.warn) { console.warn('Radio:', err); } if (surface) { showError(surface, cfg.strings.saveError || 'Preferences not saved.'); setTimeout(function () { showError(surface, null); }, 3500); } }); } /** Update play/pause button glyph + label to reflect current audio state. * v0.3.2: switched from .dashicons to a Unicode glyph so the symbol sits * on the text baseline instead of below it. */ function setPlayIcon(btn, playing) { var glyph = btn.querySelector('[data-radio-play-glyph]'); var label = btn.querySelector('[data-radio-play-label]'); if (glyph) { glyph.textContent = playing ? '‖' : '▶'; } // ‖ or ▶ if (label) { label.textContent = playing ? (cfg.strings.pause || 'Pause') : (cfg.strings.play || '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; } } /** Update the MediaSession metadata + handlers so OS media keys work. */ function updateMediaSession(audio, station) { if (!('mediaSession' in navigator) || !station) { return; } try { if (typeof window.MediaMetadata === 'function') { navigator.mediaSession.metadata = new window.MediaMetadata({ title: station.name, artist: 'SomaFM', album: station.genre || '' }); } navigator.mediaSession.setActionHandler('play', function () { audio.play(); }); navigator.mediaSession.setActionHandler('pause', function () { audio.pause(); }); } catch (e) { /* ignore — older browsers / locked-down contexts */ } } /** SomaFM current-track polling. Best-effort: stays hidden if the API * is unreachable / CORS-blocked (the plugin keeps working regardless). * v0.5.0: hands new tracks to logTrackIfNew so they land in user history. */ function startTrackPolling(player, station) { stopTrackPolling(player); var trackEl = player.querySelector('[data-radio-track]'); if (!trackEl) { return; } var code = getSomaCode(station); if (!code) { trackEl.hidden = true; return; } var url = 'https://somafm.com/songs/' + code + '.json'; function fetchTrack() { fetch(url, { credentials: 'omit', cache: 'no-store' }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { if (!data || !data.songs || !data.songs.length) { trackEl.hidden = true; return; } var s = data.songs[0]; var title = (s.title || '').trim(); var artist = (s.artist || '').trim(); if (!title && !artist) { trackEl.hidden = true; return; } trackEl.textContent = artist ? (title + ' — ' + artist) : title; trackEl.hidden = false; logTrackIfNew(artist, title, station); }) .catch(function () { trackEl.hidden = true; }); } fetchTrack(); player._trackTimer = setInterval(fetchTrack, 30000); } /** v0.5.0: POST a newly-played track to the server history. * Deduped client-side via lastLoggedSig so consecutive polls of the * same song don't spam the endpoint (server dedups too as belt+braces). */ var lastLoggedSig = ''; function logTrackIfNew(artist, title, station) { if (!cfg.ajaxUrl || !cfg.nonce) { return; } if (!artist || !title) { return; } if (artist.toLowerCase() === '(unknown)') { return; } var sig = (artist + '|' + title + '|' + ((station && station.id) || '')).toLowerCase(); if (sig === lastLoggedSig) { return; } lastLoggedSig = sig; var fd = new FormData(); fd.append('action', 'radio_log_track'); fd.append('nonce', cfg.nonce); fd.append('artist', artist); fd.append('title', title); fd.append('station', (station && station.name) || ''); fd.append('station_id', (station && station.id) || ''); fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: fd }) .catch(function () { /* track log is best-effort */ }); } function stopTrackPolling(player) { if (player._trackTimer) { clearInterval(player._trackTimer); player._trackTimer = null; } var trackEl = player.querySelector('[data-radio-track]'); if (trackEl) { trackEl.hidden = true; trackEl.textContent = ''; } } /** Web Audio frequency visualizer — progressive upgrade over the CSS * dancing bars. Tries once per player surface; if CORS or browser * support blocks it, silently leaves the CSS bars in place. * * State machine on player._vizState: * undefined → not yet tried * 'no-webaudio' → browser lacks AudioContext * 'init-failed' → createMediaElementSource threw (e.g. already wired) * 'cors-blocked' → analyser returned all-zeros for >2s → CORS silently failed * 'ok' → live frequency data flowing → canvas visible, bars hidden */ function tryVisualizer(player, audio) { if (player._vizState) { return; } var AudioCtx = window.AudioContext || window.webkitAudioContext; if (!AudioCtx) { player._vizState = 'no-webaudio'; return; } var canvas = player.querySelector('[data-radio-viz]'); if (!canvas) { player._vizState = 'no-canvas'; return; } var ctx, srcNode, analyser; try { ctx = new AudioCtx(); srcNode = ctx.createMediaElementSource(audio); analyser = ctx.createAnalyser(); analyser.fftSize = 64; // → 32 frequency bins, plenty for a 60px-wide canvas srcNode.connect(analyser); analyser.connect(ctx.destination); // KEEP audio audible } catch (e) { if (window.console && console.warn) { console.warn('Radio viz: init failed —', e.message || e); } player._vizState = 'init-failed'; return; } if (ctx.state === 'suspended') { ctx.resume(); } // Match the canvas backing-store to its CSS size × DPR for crispness. var dpr = window.devicePixelRatio || 1; canvas.width = Math.max(1, canvas.offsetWidth * dpr); canvas.height = Math.max(1, canvas.offsetHeight * dpr); var c2d = canvas.getContext('2d'); var data = new Uint8Array(analyser.frequencyBinCount); var bars = player.querySelector('.radio-player__bars'); // Stash on the player so play/pause/error handlers can drive it. player._viz = { ctx: ctx, analyser: analyser, canvas: canvas, c2d: c2d, data: data, bars: bars, hasData: false, firstAt: 0, raf: null }; player._vizState = 'ok'; // optimistic; flipped to 'cors-blocked' if data stays zero } function startVizLoop(player, audio) { if (player._vizState !== 'ok' || !player._viz) { return; } var v = player._viz; if (v.raf) { return; } // already running if (v.ctx.state === 'suspended') { v.ctx.resume(); } v.firstAt = Date.now(); var color = (getComputedStyle(player).getPropertyValue('--wp-admin-theme-color') || '#2271b1').trim() || '#2271b1'; function draw() { if (audio.paused) { v.raf = null; return; } v.analyser.getByteFrequencyData(v.data); // Detect whether we're getting real audio (non-zero data). Without // proper CORS the analyser returns all-zeros silently. if (!v.hasData) { for (var j = 0; j < v.data.length; j++) { if (v.data[j] > 0) { v.hasData = true; break; } } if (!v.hasData && Date.now() - v.firstAt > 2000) { // Two seconds of silence from the analyser = CORS blocked. // Fall back to the CSS bars and stop trying. player._vizState = 'cors-blocked'; v.canvas.hidden = true; if (v.bars) { v.bars.hidden = false; } v.raf = null; return; } } if (v.hasData) { // First frame with data → swap from CSS bars to canvas. if (v.bars && !v.bars.hidden) { v.bars.hidden = true; v.canvas.hidden = false; } v.c2d.clearRect(0, 0, v.canvas.width, v.canvas.height); v.c2d.fillStyle = color; var bw = v.canvas.width / v.data.length; for (var i = 0; i < v.data.length; i++) { var h = (v.data[i] / 255) * v.canvas.height; v.c2d.fillRect(i * bw, v.canvas.height - h, Math.max(1, bw - 1), h); } } v.raf = requestAnimationFrame(draw); } v.raf = requestAnimationFrame(draw); } function stopVizLoop(player) { if (player._viz && player._viz.raf) { cancelAnimationFrame(player._viz.raf); player._viz.raf = null; } } /** Mute toggle. Remembers the volume at mute-time so unmute restores it. */ function bindMute(player, audio, volumeIn, volPctEl) { var muteBtn = player.querySelector('[data-radio-mute]'); if (!muteBtn) { return; } muteBtn.addEventListener('click', function () { var icon = muteBtn.querySelector('.dashicons'); if (audio.muted || audio.volume === 0) { // Unmute → restore stored prior volume (default 0.6 if we never had one) var prev = parseFloat(muteBtn.dataset.prevVolume || '0.6'); audio.muted = false; audio.volume = prev; if (volumeIn) { volumeIn.value = Math.round(prev * 100); } if (volPctEl) { volPctEl.textContent = Math.round(prev * 100) + '%'; } muteBtn.classList.remove('radio-player__mute--muted'); muteBtn.setAttribute('aria-label', cfg.strings.mute || 'Mute'); if (icon) { icon.className = 'dashicons dashicons-controls-volumeon'; } } else { // Mute → store current volume, drop to 0 muteBtn.dataset.prevVolume = String(audio.volume); audio.muted = true; muteBtn.classList.add('radio-player__mute--muted'); muteBtn.setAttribute('aria-label', cfg.strings.unmute || 'Unmute'); if (icon) { icon.className = 'dashicons dashicons-controls-volumeoff'; } } }); } /** 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); updateMediaSession(audio, station); } 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') { 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); player.classList.add('is-playing'); // CSS dancing-bars animation tryVisualizer(player, audio); // idempotent — only initialises once startVizLoop(player, audio); // no-op unless viz state is 'ok' startTrackPolling(player, findStation(stationSel.value)); }); audio.addEventListener('pause', function () { setPlayIcon(playBtn, false); player.classList.remove('is-playing'); stopVizLoop(player); stopTrackPolling(player); }); audio.addEventListener('error', function () { showError(player, cfg.strings.error || 'Stream error.'); setPlayIcon(playBtn, false); player.classList.remove('is-playing'); stopVizLoop(player); stopTrackPolling(player); }); 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 || ''; } var genreEl = player.querySelector('[data-radio-genre]'); if (genreEl) { genreEl.textContent = s.genre || ''; } stopTrackPolling(player); // clear last station's track updateMediaSession(audio, s); mirrorSelection(player, newId); saveState({ station_id: newId }, player); 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.muted = false; audio.volume = vol; if (volPctEl) { volPctEl.textContent = pct + '%'; } // Manual volume change exits mute state visibly. var muteBtn = player.querySelector('[data-radio-mute]'); if (muteBtn) { muteBtn.classList.remove('radio-player__mute--muted'); var icon = muteBtn.querySelector('.dashicons'); if (icon) { icon.className = 'dashicons dashicons-controls-volumeon'; } } mirrorVolume(player, pct); clearTimeout(volumeIn._saveTimer); volumeIn._saveTimer = setTimeout(function () { saveState({ volume: vol }, player); }, 400); }); } bindMute(player, audio, volumeIn, volPctEl); bindPopOut(player); // v0.6.0: popout auto-resume — when opened with ?play=1, immediately // pick up where the main tab left off. Same-origin user-gesture popups // are exempt from autoplay blocking on every modern browser. if (cfg.autoPlay) { setTimeout(function () { if (audio.paused) { audio.play().catch(function () { /* autoplay denied — user just clicks play */ }); } }, 200); } } /** v0.6.0: Pop-out window button. Opens a 380×560 standalone window * with just the player chrome (no WP admin), via admin-post.php? * action=radio_popout&play=1. The popup persists across main-tab * navigation so background music doesn't cut when you move around * the WP admin. Pauses every other audio surface in this tab so * there's only one stream playing at a time. */ function bindPopOut(player) { var btn = player.querySelector('[data-radio-popout]'); if (!btn) { return; } if (!cfg.popoutUrl) { btn.style.display = 'none'; return; } // already in popout btn.addEventListener('click', function () { var w = window.open( cfg.popoutUrl + '&play=1', 'radio_popout', 'width=380,height=560,resizable=yes,scrollbars=no,toolbar=no,location=no,menubar=no,status=no' ); if (!w) { window.alert('Pop-out blocked by the browser. Allow popups for this site, then try again.'); return; } w.focus(); // Pause every audio surface in this tab — the popup is the new player. document.querySelectorAll('[data-radio-audio]').forEach(function (a) { if (!a.paused) { a.pause(); } }); }); } /** 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]'); var gn = other.querySelector('[data-radio-genre]'); if (nm) { nm.textContent = s.name; } if (dc) { dc.textContent = s.description || ''; } if (gn) { gn.textContent = s.genre || ''; } } } }); } /** 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; au.muted = false; } } }); } /** Wire the Settings page volume slider's live percentage label. * Replaces the inline `oninput` attribute removed in v0.3.0. */ function bindSettingsSlider() { var slider = document.getElementById('default_volume'); var label = document.getElementById('default_volume_label'); if (!slider || !label) { return; } slider.addEventListener('input', function () { label.textContent = slider.value + '%'; }); } /** v0.5.0: wire the History admin page — filter, favourite-toggle, * clear-history. No-op on pages without these elements. */ function bindHistoryPage() { var searchIn = document.getElementById('radio-history-search'); var stationSel = document.getElementById('radio-history-station'); var rows = document.querySelectorAll('.radio-history-row'); var clearBtn = document.getElementById('radio-history-clear'); function applyFilter() { if (!rows.length) { return; } var q = (searchIn ? searchIn.value : '').toLowerCase().trim(); var s = stationSel ? stationSel.value : ''; rows.forEach(function (row) { var matchQ = !q || (row.dataset.search || '').indexOf(q) !== -1; var matchS = !s || row.dataset.stationId === s; row.classList.toggle('is-filtered', !(matchQ && matchS)); }); } if (searchIn) { searchIn.addEventListener('input', applyFilter); } if (stationSel) { stationSel.addEventListener('change', applyFilter); } // Favourite-toggle buttons (per row) document.querySelectorAll('.radio-fav-btn').forEach(function (btn) { btn.addEventListener('click', function () { var fd = new FormData(); fd.append('action', 'radio_toggle_favourite'); fd.append('nonce', btn.dataset.nonce || ''); fd.append('artist', btn.dataset.artist || ''); fd.append('title', btn.dataset.title || ''); fd.append('station', btn.dataset.station || ''); fd.append('station_id', btn.dataset.stationId || ''); btn.disabled = true; fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: fd }) .then(function (r) { return r.json(); }) .then(function (res) { btn.disabled = false; if (!res || !res.success) { return; } var isFav = !!(res.data && res.data.favourite); btn.classList.toggle('is-fav', isFav); btn.textContent = isFav ? '★' : '☆'; btn.setAttribute('aria-label', isFav ? (cfg.strings.removeFav || 'Remove from favourites') : (cfg.strings.addFav || 'Add to favourites')); }) .catch(function () { btn.disabled = false; }); }); }); // Clear-history button (history tab only) if (clearBtn) { clearBtn.addEventListener('click', function () { var msg = cfg.strings.clearConfirm || 'Clear all track history? (Favourites are kept.)'; if (!window.confirm(msg)) { return; } var fd = new FormData(); fd.append('action', 'radio_clear_history'); fd.append('nonce', clearBtn.dataset.nonce || ''); clearBtn.disabled = true; fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: fd }) .then(function (r) { return r.json(); }) .then(function (res) { if (res && res.success) { window.location.reload(); } else { clearBtn.disabled = false; } }) .catch(function () { clearBtn.disabled = false; }); }); } } function init() { var players = document.querySelectorAll('.radio-player'); for (var i = 0; i < players.length; i++) { bindPlayer(players[i]); } bindSettingsSlider(); bindHistoryPage(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();