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,
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue