From 63afc55836864d0491e772e9f6ca2ea821f1fc9f Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 11:19:19 +0100 Subject: [PATCH] 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 --- server/src/plugins/radio/index.ts | 68 +++++++++++ web/src/plugins/radio/RadioTab.tsx | 93 ++++++++++++++- web/src/plugins/radio/TileTextureManager.ts | 7 +- web/src/styles.css | 123 ++++++++++++++++++++ 4 files changed, 286 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/radio/index.ts b/server/src/plugins/radio/index.ts index 54482a6..5c94d78 100644 --- a/server/src/plugins/radio/index.ts +++ b/server/src/plugins/radio/index.ts @@ -14,6 +14,15 @@ import { resolveStreamUrl, getPlacesCount, } from './api.js'; +// ── Tile proxy cache (in-memory) ── +const TILE_CDN = 'https://rg-tiles.b-cdn.net'; +const tileCache = new Map(); +const TILE_CACHE_MAX = 500; // max cached tiles (~15-25 MB) + +// ── Voice connection tracking ── +const connectedSince = new Map(); +let voiceStatsInterval: ReturnType | undefined; + // ── Types ── interface GuildRadioState { stationId: string; @@ -68,6 +77,7 @@ function stopStream(guildId: string): void { try { state.player.stop(true); } catch {} try { getVoiceConnection(guildId, 'radio')?.destroy(); } catch {} guildRadioState.delete(guildId); + connectedSince.delete(guildId); broadcastState(guildId); console.log(`[Radio] Stopped stream in guild ${guildId}`); } @@ -195,6 +205,25 @@ const radioPlugin: Plugin = { async onReady(ctx) { 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) { @@ -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 ── app.get('/api/radio/guilds', (_req, res) => { const guilds = ctx.client.guilds.cache.map(g => ({ @@ -356,6 +423,7 @@ const radioPlugin: Plugin = { }, async destroy() { + if (voiceStatsInterval) clearInterval(voiceStatsInterval); for (const guildId of guildRadioState.keys()) { stopStream(guildId); } diff --git a/web/src/plugins/radio/RadioTab.tsx b/web/src/plugins/radio/RadioTab.tsx index 671ef0b..3a4d7dd 100644 --- a/web/src/plugins/radio/RadioTab.tsx +++ b/web/src/plugins/radio/RadioTab.tsx @@ -48,6 +48,14 @@ interface Favorite { placeId: string; } +interface VoiceStats { + voicePing: number | null; + gatewayPing: number; + status: string; + channelName: string | null; + connectedSince: string | null; +} + const THEMES = [ { id: 'default', color: '#e67e22', label: 'Sunset' }, { id: 'purple', color: '#9b59b6', label: 'Midnight' }, @@ -80,8 +88,11 @@ export default function RadioTab({ data }: { data: any }) { const [showFavorites, setShowFavorites] = useState(false); const [playingLoading, setPlayingLoading] = useState(false); const [volume, setVolume] = useState(0.5); + const [voiceStats, setVoiceStats] = useState(null); + const [showConnModal, setShowConnModal] = useState(false); const searchTimeout = useRef>(undefined); const volumeTimeout = useRef>(undefined); + const selectedGuildRef = useRef(selectedGuild); // ── Fetch initial data ── 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); }, []); + // Keep selectedGuildRef in sync + useEffect(() => { selectedGuildRef.current = selectedGuild; }, [selectedGuild]); + // ── Handle SSE data ── 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 setNowPlaying(prev => { if (data.playing) { @@ -114,7 +128,7 @@ export default function RadioTab({ data }: { data: any }) { delete next[data.guildId]; return next; }); - } else if (data?.playing) { + } else if (data?.playing && !data?.guildId) { // Snapshot: playing is Record setNowPlaying(data.playing); } @@ -126,6 +140,16 @@ export default function RadioTab({ data }: { data: any }) { if (data?.volume != null && data?.guildId === selectedGuild) { 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]); // ── Theme persist + update globe colors ── @@ -589,6 +613,13 @@ export default function RadioTab({ data }: { data: any }) { {Math.round(volume * 100)}% {'\u{1F50A}'} {currentPlaying.channelName} +
setShowConnModal(true)} title="Verbindungsdetails"> + + Verbunden + {voiceStats?.voicePing != null && ( + {voiceStats.voicePing}ms + )} +
)} @@ -598,6 +629,64 @@ export default function RadioTab({ data }: { data: any }) {
{'\u{1F4FB}'} {places.length.toLocaleString('de-DE')} Sender weltweit
+ + {/* ── 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 ( +
setShowConnModal(false)}> +
e.stopPropagation()}> +
+ {'\u{1F4E1}'} + Verbindungsdetails + +
+
+
+ Voice Ping + + + {voiceStats.voicePing != null ? `${voiceStats.voicePing} ms` : '---'} + +
+
+ Gateway Ping + + + {voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'} + +
+
+ Status + + {voiceStats.status === 'ready' ? 'Verbunden' : voiceStats.status} + +
+
+ Kanal + {voiceStats.channelName || '---'} +
+
+ Verbunden seit + {uptimeStr} +
+
+
+
+ ); + })()} ); } diff --git a/web/src/plugins/radio/TileTextureManager.ts b/web/src/plugins/radio/TileTextureManager.ts index 3ca20b4..7a5f4fc 100644 --- a/web/src/plugins/radio/TileTextureManager.ts +++ b/web/src/plugins/radio/TileTextureManager.ts @@ -6,7 +6,8 @@ * 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 ── @@ -114,7 +115,7 @@ export class TileTextureManager { this.loading.add(k); return new Promise((resolve) => { const img = new Image(); - img.crossOrigin = 'anonymous'; + // No crossOrigin needed — tiles served from our own server img.onload = () => { this.cache.set(k, img); this.loading.delete(k); @@ -125,7 +126,7 @@ export class TileTextureManager { this.loading.delete(k); resolve(); }; - img.src = `${TILE_CDN}/${k}.jpg`; + img.src = `${TILE_CDN}/${k}`; }); } diff --git a/web/src/styles.css b/web/src/styles.css index dd35ca1..163e95e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1079,3 +1079,126 @@ html, body { 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; +}