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