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:
parent
b9a9347356
commit
63afc55836
4 changed files with 286 additions and 5 deletions
|
|
@ -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<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 ──
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<VoiceStats | null>(null);
|
||||
const [showConnModal, setShowConnModal] = useState(false);
|
||||
const searchTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const volumeTimeout = useRef<ReturnType<typeof setTimeout>>(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<string, NowPlaying>
|
||||
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 }) {
|
|||
<span className="radio-volume-val">{Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -598,6 +629,64 @@ export default function RadioTab({ data }: { data: any }) {
|
|||
<div className="radio-counter">
|
||||
{'\u{1F4FB}'} {places.length.toLocaleString('de-DE')} Sender weltweit
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>((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}`;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue