Fix: Tile proxy for black globe + radio voicestats modal

- Globe was black because Radio Garden CDN (rg-tiles.b-cdn.net) returns
  403 without Referer: radio.garden header. Added server-side tile proxy
  /api/radio/tile/:z/:x/:y with in-memory cache (max 500 tiles).
- Added radio_voicestats SSE broadcast (every 5s) with voice ping,
  gateway ping, status, channel name, and connected-since timestamp.
- Added clickable "Verbunden" connection indicator in RadioTab bottom
  bar with live ping display and connection details modal (matching
  soundboard's existing modal pattern).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 11:19:19 +01:00
parent b9a9347356
commit 63afc55836
4 changed files with 286 additions and 5 deletions

View file

@ -14,6 +14,15 @@ import {
resolveStreamUrl, getPlacesCount, resolveStreamUrl, getPlacesCount,
} from './api.js'; } from './api.js';
// ── Tile proxy cache (in-memory) ──
const TILE_CDN = 'https://rg-tiles.b-cdn.net';
const tileCache = new Map<string, { data: Buffer; contentType: string }>();
const TILE_CACHE_MAX = 500; // max cached tiles (~15-25 MB)
// ── Voice connection tracking ──
const connectedSince = new Map<string, string>();
let voiceStatsInterval: ReturnType<typeof setInterval> | undefined;
// ── Types ── // ── Types ──
interface GuildRadioState { interface GuildRadioState {
stationId: string; stationId: string;
@ -68,6 +77,7 @@ function stopStream(guildId: string): void {
try { state.player.stop(true); } catch {} try { state.player.stop(true); } catch {}
try { getVoiceConnection(guildId, 'radio')?.destroy(); } catch {} try { getVoiceConnection(guildId, 'radio')?.destroy(); } catch {}
guildRadioState.delete(guildId); guildRadioState.delete(guildId);
connectedSince.delete(guildId);
broadcastState(guildId); broadcastState(guildId);
console.log(`[Radio] Stopped stream in guild ${guildId}`); console.log(`[Radio] Stopped stream in guild ${guildId}`);
} }
@ -195,6 +205,25 @@ const radioPlugin: Plugin = {
async onReady(ctx) { async onReady(ctx) {
console.log(`[Radio] Discord ready ${ctx.client.guilds.cache.size} Guild(s)`); console.log(`[Radio] Discord ready ${ctx.client.guilds.cache.size} Guild(s)`);
// Voice stats broadcast every 5s
voiceStatsInterval = setInterval(() => {
if (guildRadioState.size === 0) return;
for (const [gId, st] of guildRadioState) {
const conn = getVoiceConnection(gId, 'radio');
const status = conn?.state?.status ?? 'unknown';
if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString());
const ch = ctx.client.channels.cache.get(st.channelId);
sseBroadcast({
type: 'radio_voicestats', plugin: 'radio', guildId: gId,
voicePing: (conn?.ping as any)?.ws ?? null,
gatewayPing: ctx.client.ws.ping,
status,
channelName: ch && 'name' in ch ? (ch as any).name : null,
connectedSince: connectedSince.get(gId) ?? null,
});
}
}, 5_000);
}, },
registerRoutes(app: express.Application, ctx: PluginContext) { registerRoutes(app: express.Application, ctx: PluginContext) {
@ -231,6 +260,44 @@ const radioPlugin: Plugin = {
} }
}); });
// ── Tile Proxy (Radio Garden CDN requires Referer) ──
app.get('/api/radio/tile/:z/:x/:y', async (req, res) => {
const { z, x, y } = req.params;
const key = `${z}/${x}/${y}`;
// Serve from cache
const cached = tileCache.get(key);
if (cached) {
res.set('Content-Type', cached.contentType);
res.set('Cache-Control', 'public, max-age=86400');
res.send(cached.data);
return;
}
try {
const resp = await fetch(`${TILE_CDN}/${key}.jpg`, {
headers: { Referer: 'https://radio.garden/' },
});
if (!resp.ok) { res.status(resp.status).end(); return; }
const buf = Buffer.from(await resp.arrayBuffer());
const ct = resp.headers.get('content-type') ?? 'image/jpeg';
// Cache (evict oldest if full)
if (tileCache.size >= TILE_CACHE_MAX) {
const first = tileCache.keys().next().value;
if (first) tileCache.delete(first);
}
tileCache.set(key, { data: buf, contentType: ct });
res.set('Content-Type', ct);
res.set('Cache-Control', 'public, max-age=86400');
res.send(buf);
} catch {
res.status(502).end();
}
});
// ── Verfügbare Guilds + Voice Channels ── // ── Verfügbare Guilds + Voice Channels ──
app.get('/api/radio/guilds', (_req, res) => { app.get('/api/radio/guilds', (_req, res) => {
const guilds = ctx.client.guilds.cache.map(g => ({ const guilds = ctx.client.guilds.cache.map(g => ({
@ -356,6 +423,7 @@ const radioPlugin: Plugin = {
}, },
async destroy() { async destroy() {
if (voiceStatsInterval) clearInterval(voiceStatsInterval);
for (const guildId of guildRadioState.keys()) { for (const guildId of guildRadioState.keys()) {
stopStream(guildId); stopStream(guildId);
} }

View file

@ -48,6 +48,14 @@ interface Favorite {
placeId: string; placeId: string;
} }
interface VoiceStats {
voicePing: number | null;
gatewayPing: number;
status: string;
channelName: string | null;
connectedSince: string | null;
}
const THEMES = [ const THEMES = [
{ id: 'default', color: '#e67e22', label: 'Sunset' }, { id: 'default', color: '#e67e22', label: 'Sunset' },
{ id: 'purple', color: '#9b59b6', label: 'Midnight' }, { id: 'purple', color: '#9b59b6', label: 'Midnight' },
@ -80,8 +88,11 @@ export default function RadioTab({ data }: { data: any }) {
const [showFavorites, setShowFavorites] = useState(false); const [showFavorites, setShowFavorites] = useState(false);
const [playingLoading, setPlayingLoading] = useState(false); const [playingLoading, setPlayingLoading] = useState(false);
const [volume, setVolume] = useState(0.5); const [volume, setVolume] = useState(0.5);
const [voiceStats, setVoiceStats] = useState<VoiceStats | null>(null);
const [showConnModal, setShowConnModal] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout>>(undefined); const searchTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const volumeTimeout = useRef<ReturnType<typeof setTimeout>>(undefined); const volumeTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const selectedGuildRef = useRef(selectedGuild);
// ── Fetch initial data ── // ── Fetch initial data ──
useEffect(() => { useEffect(() => {
@ -102,9 +113,12 @@ export default function RadioTab({ data }: { data: any }) {
fetch('/api/radio/favorites').then(r => r.json()).then(setFavorites).catch(console.error); fetch('/api/radio/favorites').then(r => r.json()).then(setFavorites).catch(console.error);
}, []); }, []);
// Keep selectedGuildRef in sync
useEffect(() => { selectedGuildRef.current = selectedGuild; }, [selectedGuild]);
// ── Handle SSE data ── // ── Handle SSE data ──
useEffect(() => { useEffect(() => {
if (data?.guildId && 'playing' in data) { if (data?.guildId && 'playing' in data && data.type !== 'radio_voicestats') {
// Per-guild SSE event (broadcastState): playing is single NowPlaying | null // Per-guild SSE event (broadcastState): playing is single NowPlaying | null
setNowPlaying(prev => { setNowPlaying(prev => {
if (data.playing) { if (data.playing) {
@ -114,7 +128,7 @@ export default function RadioTab({ data }: { data: any }) {
delete next[data.guildId]; delete next[data.guildId];
return next; return next;
}); });
} else if (data?.playing) { } else if (data?.playing && !data?.guildId) {
// Snapshot: playing is Record<string, NowPlaying> // Snapshot: playing is Record<string, NowPlaying>
setNowPlaying(data.playing); setNowPlaying(data.playing);
} }
@ -126,6 +140,16 @@ export default function RadioTab({ data }: { data: any }) {
if (data?.volume != null && data?.guildId === selectedGuild) { if (data?.volume != null && data?.guildId === selectedGuild) {
setVolume(data.volume); setVolume(data.volume);
} }
// Voice stats
if (data?.type === 'radio_voicestats' && data.guildId === selectedGuildRef.current) {
setVoiceStats({
voicePing: data.voicePing,
gatewayPing: data.gatewayPing,
status: data.status,
channelName: data.channelName,
connectedSince: data.connectedSince,
});
}
}, [data, selectedGuild]); }, [data, selectedGuild]);
// ── Theme persist + update globe colors ── // ── Theme persist + update globe colors ──
@ -589,6 +613,13 @@ export default function RadioTab({ data }: { data: any }) {
<span className="radio-volume-val">{Math.round(volume * 100)}%</span> <span className="radio-volume-val">{Math.round(volume * 100)}%</span>
</div> </div>
<span className="radio-np-ch">{'\u{1F50A}'} {currentPlaying.channelName}</span> <span className="radio-np-ch">{'\u{1F50A}'} {currentPlaying.channelName}</span>
<div className="radio-conn" onClick={() => setShowConnModal(true)} title="Verbindungsdetails">
<span className="radio-conn-dot" />
Verbunden
{voiceStats?.voicePing != null && (
<span className="radio-conn-ping">{voiceStats.voicePing}ms</span>
)}
</div>
<button className="radio-btn-stop" onClick={handleStop}>{'\u23F9'} Stop</button> <button className="radio-btn-stop" onClick={handleStop}>{'\u23F9'} Stop</button>
</div> </div>
)} )}
@ -598,6 +629,64 @@ export default function RadioTab({ data }: { data: any }) {
<div className="radio-counter"> <div className="radio-counter">
{'\u{1F4FB}'} {places.length.toLocaleString('de-DE')} Sender weltweit {'\u{1F4FB}'} {places.length.toLocaleString('de-DE')} Sender weltweit
</div> </div>
{/* ── Connection Details Modal ── */}
{showConnModal && voiceStats && (() => {
const uptimeSec = voiceStats.connectedSince
? Math.floor((Date.now() - new Date(voiceStats.connectedSince).getTime()) / 1000)
: 0;
const h = Math.floor(uptimeSec / 3600);
const m = Math.floor((uptimeSec % 3600) / 60);
const s = uptimeSec % 60;
const uptimeStr = h > 0
? `${h}h ${String(m).padStart(2, '0')}m ${String(s).padStart(2, '0')}s`
: m > 0
? `${m}m ${String(s).padStart(2, '0')}s`
: `${s}s`;
const pingColor = (ms: number | null) =>
ms == null ? 'var(--text-faint)' : ms < 80 ? 'var(--success)' : ms < 150 ? '#f0a830' : '#e04040';
return (
<div className="radio-modal-overlay" onClick={() => setShowConnModal(false)}>
<div className="radio-modal" onClick={e => e.stopPropagation()}>
<div className="radio-modal-header">
<span>{'\u{1F4E1}'}</span>
<span>Verbindungsdetails</span>
<button className="radio-modal-close" onClick={() => setShowConnModal(false)}>{'\u2715'}</button>
</div>
<div className="radio-modal-body">
<div className="radio-modal-stat">
<span className="radio-modal-label">Voice Ping</span>
<span className="radio-modal-value">
<span className="radio-modal-dot" style={{ background: pingColor(voiceStats.voicePing) }} />
{voiceStats.voicePing != null ? `${voiceStats.voicePing} ms` : '---'}
</span>
</div>
<div className="radio-modal-stat">
<span className="radio-modal-label">Gateway Ping</span>
<span className="radio-modal-value">
<span className="radio-modal-dot" style={{ background: pingColor(voiceStats.gatewayPing) }} />
{voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'}
</span>
</div>
<div className="radio-modal-stat">
<span className="radio-modal-label">Status</span>
<span className="radio-modal-value" style={{ color: voiceStats.status === 'ready' ? 'var(--success)' : '#f0a830' }}>
{voiceStats.status === 'ready' ? 'Verbunden' : voiceStats.status}
</span>
</div>
<div className="radio-modal-stat">
<span className="radio-modal-label">Kanal</span>
<span className="radio-modal-value">{voiceStats.channelName || '---'}</span>
</div>
<div className="radio-modal-stat">
<span className="radio-modal-label">Verbunden seit</span>
<span className="radio-modal-value">{uptimeStr}</span>
</div>
</div>
</div>
</div>
);
})()}
</div> </div>
); );
} }

View file

@ -6,7 +6,8 @@
* Mercator equirectangular reprojection when painting onto the canvas. * Mercator equirectangular reprojection when painting onto the canvas.
*/ */
const TILE_CDN = 'https://rg-tiles.b-cdn.net'; // Proxy through our server (CDN requires Referer: radio.garden)
const TILE_CDN = '/api/radio/tile';
// ── Mercator math ── // ── Mercator math ──
@ -114,7 +115,7 @@ export class TileTextureManager {
this.loading.add(k); this.loading.add(k);
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; // No crossOrigin needed — tiles served from our own server
img.onload = () => { img.onload = () => {
this.cache.set(k, img); this.cache.set(k, img);
this.loading.delete(k); this.loading.delete(k);
@ -125,7 +126,7 @@ export class TileTextureManager {
this.loading.delete(k); this.loading.delete(k);
resolve(); resolve();
}; };
img.src = `${TILE_CDN}/${k}.jpg`; img.src = `${TILE_CDN}/${k}`;
}); });
} }

View file

@ -1079,3 +1079,126 @@ html, body {
max-width: 120px; max-width: 120px;
} }
} }
/* ── Radio Connection Indicator ── */
.radio-conn {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--success);
cursor: pointer;
padding: 4px 10px;
border-radius: 20px;
background: rgba(87, 210, 143, 0.08);
transition: all var(--transition);
flex-shrink: 0;
user-select: none;
}
.radio-conn:hover {
background: rgba(87, 210, 143, 0.15);
}
.radio-conn-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
animation: pulse-dot 2s ease-in-out infinite;
}
.radio-conn-ping {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* ── Radio Connection Modal ── */
.radio-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .55);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
animation: fade-in .15s ease;
}
.radio-modal {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 16px;
width: 340px;
box-shadow: 0 20px 60px rgba(0, 0, 0, .4);
overflow: hidden;
animation: radio-modal-in .2s ease;
}
@keyframes radio-modal-in {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.radio-modal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 14px;
}
.radio-modal-close {
margin-left: auto;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
font-size: 14px;
transition: all var(--transition);
}
.radio-modal-close:hover {
background: rgba(255, 255, 255, .08);
color: var(--text-normal);
}
.radio-modal-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-modal-stat {
display: flex;
justify-content: space-between;
align-items: center;
}
.radio-modal-label {
color: var(--text-muted);
font-size: 13px;
}
.radio-modal-value {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
.radio-modal-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}