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

@ -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>
);
}