GODSIGH/js/layers/radiogarden.js
type-two fcdcef760a fix: all data fetches use cache:'no-store' — browser-cache poisoning guard
During the nginx outage the proxy URLs briefly served the landing page
WITH cacheable headers; browsers cached that HTML for the proxy URLs and
kept reading it after the origin recovered (satellites 'no satellites',
empty aircraft). no-store on every feed fetch means the browser cache can
never serve (or store) a poisoned body for data endpoints again. The
proxies are no-store server-side anyway — this closes the outage-window
loophole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:59:59 +10:00

136 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Radio Garden layer (Wave 6) — ~8,000 live radio stations pinned to their real
// cities. Click a dot → pick a station → it streams live through a small player.
// Place/channel JSON comes via the same-origin proxy (proxy/rg/…, no CORS
// upstream); the AUDIO itself streams straight from radio.garden — <audio> media
// loads don't need CORS. Unofficial API: default-off, fails soft.
export default function create(ctx) {
const { viewer, Cesium, lib, ui } = ctx;
const pts = viewer.scene.primitives.add(new Cesium.PointPrimitiveCollection());
pts.show = false;
let enabled = false;
let loaded = false;
const MINT = Cesium.Color.fromCssColorString('#7de3a0').withAlpha(0.85);
const OUTLINE = Cesium.Color.BLACK;
ui.addLayer('radio', 'Radio Garden (live radio)', false, (on) => {
enabled = on;
pts.show = on;
if (on && !loaded) load();
if (!on) { stopAudio(); hidePlayer(); ui.setStatus('radio', '', 'ok'); }
else if (loaded) ui.setStatus('radio', `${pts.length} stations · click to listen`, 'ok');
});
async function load() {
ui.setStatus('radio', 'loading stations…', 'warn');
let res;
try {
res = await fetch('proxy/rg/ara/content/places', { cache: 'no-store' });
} catch {
ui.setStatus('radio', 'network error', 'err'); return;
}
if (!res.ok) { ui.setStatus('radio', `HTTP ${res.status}`, 'err'); return; }
let list;
try {
list = (await res.json()).data.list;
} catch {
ui.setStatus('radio', 'bad response', 'err'); return;
}
for (const p of list) {
const g = p.geo; // [lon, lat]
if (!Array.isArray(g) || !isFinite(g[0]) || !isFinite(g[1])) continue;
pts.add({
position: Cesium.Cartesian3.fromDegrees(g[0], g[1]),
pixelSize: 4,
color: MINT,
outlineColor: OUTLINE,
outlineWidth: 1,
disableDepthTestDistance: 50000,
id: { layer: 'radio', placeId: p.url.split('/').pop(), title: p.title, country: p.country, size: p.size },
});
}
loaded = true;
if (enabled) ui.setStatus('radio', `${pts.length} stations · click to listen`, 'ok');
}
// ---- player ----
const audio = new Audio();
audio.volume = 0.85;
let panel = null;
let playingBtn = null;
function ensurePanel() {
if (panel) return panel;
panel = document.createElement('aside');
panel.id = 'radio-player';
panel.hidden = true;
panel.innerHTML = `<div class="rp-head"><span class="rp-title"></span><span class="rp-close" title="Close & stop">×</span></div>
<div class="rp-list"></div>
<div class="rp-foot">radio.garden · streams are live</div>`;
panel.querySelector('.rp-close').addEventListener('click', () => { stopAudio(); hidePlayer(); });
document.body.appendChild(panel);
return panel;
}
function stopAudio() {
audio.pause();
audio.removeAttribute('src');
audio.load();
if (playingBtn) { playingBtn.textContent = '▶'; playingBtn = null; }
}
function hidePlayer() { if (panel) panel.hidden = true; }
async function openPlayer(meta) {
const p = ensurePanel();
p.querySelector('.rp-title').textContent = `📻 ${meta.title}${meta.country ? ' · ' + meta.country : ''}`;
const listEl = p.querySelector('.rp-list');
listEl.innerHTML = '<div class="rp-hint">tuning…</div>';
p.hidden = false;
let items = [];
try {
const res = await fetch(`proxy/rg/ara/content/page/${encodeURIComponent(meta.placeId)}/channels`, { cache: 'no-store' });
const j = await res.json();
for (const block of (j.data && j.data.content) || []) {
for (const it of block.items || []) {
const pg = it.page || it;
if (pg && pg.url && pg.title) items.push({ id: pg.url.split('/').pop(), title: pg.title });
}
}
} catch { /* fall through to empty-state */ }
if (!items.length) { listEl.innerHTML = '<div class="rp-hint">no stations found</div>'; return; }
listEl.innerHTML = '';
for (const st of items.slice(0, 14)) {
const row = document.createElement('div');
row.className = 'rp-row';
const btn = document.createElement('button');
btn.className = 'rp-play';
btn.textContent = '▶';
const name = document.createElement('span');
name.textContent = st.title; // textContent — no HTML injection possible
btn.addEventListener('click', () => {
if (playingBtn === btn) { stopAudio(); return; } // toggle off
stopAudio();
// The mp3 endpoint 302s to the real stream; <audio> follows it, no CORS needed.
audio.src = `https://radio.garden/api/ara/content/listen/${st.id}/channel.mp3`;
audio.play().catch(() => { btn.textContent = '✕'; });
btn.textContent = '■';
playingBtn = btn;
});
row.appendChild(btn);
row.appendChild(name);
listEl.appendChild(row);
}
}
function handlePick(picked) {
if (!enabled || picked?.id?.layer !== 'radio') return false;
openPlayer(picked.id);
return true;
}
// NOTE: no clearPick — the radio keeps playing while you pan the globe; the
// player's × is the way to stop. That's the point of ambient world radio.
return { id: 'radio', handlePick };
}