/** * 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. * * 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). */ 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; }) .catch(function () { trackEl.hidden = true; }); } fetchTrack(); player._trackTimer = setInterval(fetchTrack, 30000); } 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 = ''; } } /** 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); startTrackPolling(player, findStation(stationSel.value)); }); audio.addEventListener('pause', function () { setPlayIcon(playBtn, false); stopTrackPolling(player); }); audio.addEventListener('error', function () { showError(player, cfg.strings.error || 'Stream error.'); setPlayIcon(playBtn, false); 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); } /** 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 + '%'; }); } function init() { var players = document.querySelectorAll('.radio-player'); for (var i = 0; i < players.length; i++) { bindPlayer(players[i]); } bindSettingsSlider(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();