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>
136 lines
5.2 KiB
JavaScript
136 lines
5.2 KiB
JavaScript
// 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 };
|
||
}
|