gaming-hub/web/src/plugins/radio/RadioTab.tsx
Daniel 7786d02f86 fix: globe clickable after tab switch — deferred init with ResizeObserver
Globe.gl needs non-zero container dimensions for initialization and click
handling. With the tab persistence fix (display:none for hidden tabs), the
globe container starts at 0×0 when radio isn't the first tab. Added a
separate ResizeObserver that detects when the container becomes visible
and triggers globe initialization via containerVisible state dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:17:48 +01:00

748 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, useCallback } from 'react';
import Globe from 'globe.gl';
// ── Types ──
interface RadioPlace {
id: string;
geo: [number, number];
title: string;
country: string;
size: number;
}
interface RadioChannel {
id: string;
title: string;
}
interface NowPlaying {
stationId: string;
stationName: string;
placeName: string;
country: string;
startedAt: string;
channelName: string;
}
interface GuildInfo {
id: string;
name: string;
voiceChannels: { id: string; name: string; members: number }[];
}
interface SearchHit {
id: string;
type: string;
title: string;
subtitle: string;
url: string;
}
interface Favorite {
stationId: string;
stationName: string;
placeName: string;
country: string;
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' },
{ id: 'forest', color: '#2ecc71', label: 'Forest' },
{ id: 'ocean', color: '#3498db', label: 'Ocean' },
{ id: 'cherry', color: '#e74c6f', label: 'Cherry' },
];
// ── Zoom scaling constants ──
const BASE_ALT = 2.0;
// ── Component ──
export default function RadioTab({ data }: { data: any }) {
const containerRef = useRef<HTMLDivElement>(null);
const globeRef = useRef<any>(null);
const rotationResumeRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [theme, setTheme] = useState(() => localStorage.getItem('radio-theme') || 'default');
const [places, setPlaces] = useState<RadioPlace[]>([]);
const [selectedPlace, setSelectedPlace] = useState<RadioPlace | null>(null);
const [stations, setStations] = useState<RadioChannel[]>([]);
const [stationsLoading, setStationsLoading] = useState(false);
const [nowPlaying, setNowPlaying] = useState<Record<string, NowPlaying>>({});
const [guilds, setGuilds] = useState<GuildInfo[]>([]);
const [selectedGuild, setSelectedGuild] = useState('');
const [selectedChannel, setSelectedChannel] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchHit[]>([]);
const [searchOpen, setSearchOpen] = useState(false);
const [favorites, setFavorites] = useState<Favorite[]>([]);
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 [containerVisible, setContainerVisible] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const volumeTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const selectedGuildRef = useRef(selectedGuild);
// ── Fetch initial data ──
useEffect(() => {
fetch('/api/radio/places').then(r => r.json()).then(setPlaces).catch(console.error);
fetch('/api/radio/guilds')
.then(r => r.json())
.then((g: GuildInfo[]) => {
setGuilds(g);
if (g.length > 0) {
setSelectedGuild(g[0].id);
const ch = g[0].voiceChannels.find(c => c.members > 0) ?? g[0].voiceChannels[0];
if (ch) setSelectedChannel(ch.id);
}
})
.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 ──
useEffect(() => {
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) {
return { ...prev, [data.guildId]: data.playing };
}
const next = { ...prev };
delete next[data.guildId];
return next;
});
} else if (data?.playing && !data?.guildId) {
// Snapshot: playing is Record<string, NowPlaying>
setNowPlaying(data.playing);
}
if (data?.favorites) setFavorites(data.favorites);
// Volume from snapshot or radio_volume event
if (data?.volumes && selectedGuild && data.volumes[selectedGuild] != null) {
setVolume(data.volumes[selectedGuild]);
}
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 ──
useEffect(() => {
localStorage.setItem('radio-theme', theme);
if (globeRef.current && containerRef.current) {
const style = getComputedStyle(containerRef.current.parentElement!);
const accentRgb = style.getPropertyValue('--accent-rgb').trim();
globeRef.current
.pointColor(() => `rgba(${accentRgb}, 0.85)`)
.atmosphereColor(`rgba(${accentRgb}, 0.25)`);
}
}, [theme]);
// ── Helper: pause globe rotation for 5s (only resumes if no panel open) ──
const selectedPlaceRef = useRef(selectedPlace);
selectedPlaceRef.current = selectedPlace;
const showFavoritesRef = useRef(showFavorites);
showFavoritesRef.current = showFavorites;
const pauseRotation = useCallback(() => {
const controls = globeRef.current?.controls() as any;
if (controls) controls.autoRotate = false;
if (rotationResumeRef.current) clearTimeout(rotationResumeRef.current);
rotationResumeRef.current = setTimeout(() => {
// Only resume if no sidebar is open
if (selectedPlaceRef.current || showFavoritesRef.current) return;
const c = globeRef.current?.controls() as any;
if (c) c.autoRotate = true;
}, 5000);
}, []);
// ── Stop/resume rotation when panels open/close ──
useEffect(() => {
const controls = globeRef.current?.controls() as any;
if (!controls) return;
if (selectedPlace || showFavorites) {
controls.autoRotate = false;
if (rotationResumeRef.current) clearTimeout(rotationResumeRef.current);
} else {
controls.autoRotate = true;
}
}, [selectedPlace, showFavorites]);
// ── Point click handler (stable ref) ──
const handlePointClickRef = useRef<(point: any) => void>(undefined);
handlePointClickRef.current = (point: any) => {
setSelectedPlace(point);
setShowFavorites(false);
setStationsLoading(true);
setStations([]);
pauseRotation();
if (globeRef.current) {
// Radio Garden geo format: [lng, lat]
globeRef.current.pointOfView({ lat: point.geo[1], lng: point.geo[0], altitude: 0.4 }, 800);
}
fetch(`/api/radio/place/${point.id}/channels`)
.then(r => r.json())
.then((ch: RadioChannel[]) => { setStations(ch); setStationsLoading(false); })
.catch(() => setStationsLoading(false));
};
// ── Watch container visibility (detects tab becoming active) ──
useEffect(() => {
const el = containerRef.current;
if (!el) return;
// Check immediately
if (el.clientWidth > 0 && el.clientHeight > 0) {
setContainerVisible(true);
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
setContainerVisible(true);
}
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// ── Initialize globe ──
useEffect(() => {
if (!containerRef.current || places.length === 0) return;
// If container is hidden (display:none), wait for it to become visible
const cw = containerRef.current.clientWidth;
const ch = containerRef.current.clientHeight;
if (globeRef.current) {
globeRef.current.pointsData(places);
// Re-apply dimensions in case we were hidden during init
if (cw > 0 && ch > 0) globeRef.current.width(cw).height(ch);
return;
}
// Don't initialize globe with zero dimensions — containerVisible will re-trigger
if (cw === 0 || ch === 0) return;
// Read accent color from theme
const initStyle = getComputedStyle(containerRef.current.parentElement!);
const initRgb = initStyle.getPropertyValue('--accent-rgb').trim() || '230, 126, 34';
const globe = new Globe(containerRef.current)
.backgroundColor('rgba(0,0,0,0)')
.atmosphereColor(`rgba(${initRgb}, 0.25)`)
.atmosphereAltitude(0.12)
.globeImageUrl('/nasa-blue-marble.jpg')
.pointsData(places)
// Radio Garden geo format: [lng, lat]
.pointLat((d: any) => d.geo[1])
.pointLng((d: any) => d.geo[0])
.pointColor(() => `rgba(${initRgb}, 0.85)`)
.pointRadius((d: any) => Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005)))
.pointAltitude(0.001)
.pointResolution(24)
.pointLabel((d: any) =>
`<div style="font-family:system-ui;font-size:13px;color:#fff;background:rgba(30,31,34,0.92);padding:6px 10px;border-radius:6px;border:1px solid rgba(${initRgb},0.3);pointer-events:none">` +
`<b>${d.title}</b><br/><span style="color:#949ba4;font-size:11px">${d.country}</span></div>`
)
.onPointClick((d: any) => handlePointClickRef.current?.(d))
.width(containerRef.current.clientWidth)
.height(containerRef.current.clientHeight);
// Sharp rendering on HiDPI/Retina displays
globe.renderer().setPixelRatio(window.devicePixelRatio);
// Start-Position: Europa
globe.pointOfView({ lat: 48, lng: 10, altitude: BASE_ALT });
// Auto-Rotation
const controls = globe.controls() as any;
if (controls) {
controls.autoRotate = true;
controls.autoRotateSpeed = 0.3;
}
// ── Zoom-based dot scaling ──
// Dots scale with sqrt(altitude) so they stay visually consistent:
// zoomed out (alt 2.0) → radius 0.18°, zoomed in (alt 0.4) → ~0.08°
let lastAlt = BASE_ALT;
const onControlsChange = () => {
const pov = globe.pointOfView();
const alt = pov.altitude;
if (Math.abs(alt - lastAlt) / lastAlt < 0.05) return;
lastAlt = alt;
const scale = Math.sqrt(alt / BASE_ALT);
globe.pointRadius((d: any) => {
const base = Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005));
return base * Math.max(0.15, Math.min(2.5, scale));
});
};
controls.addEventListener('change', onControlsChange);
globeRef.current = globe;
// Pause rotation on any globe interaction (drag, scroll, touch)
const el = containerRef.current;
const onInteract = () => pauseRotation();
el.addEventListener('mousedown', onInteract);
el.addEventListener('touchstart', onInteract);
el.addEventListener('wheel', onInteract);
const onResize = () => {
if (containerRef.current && globeRef.current) {
const w = containerRef.current.clientWidth;
const h = containerRef.current.clientHeight;
if (w > 0 && h > 0) {
globeRef.current.width(w).height(h);
}
}
};
window.addEventListener('resize', onResize);
// ResizeObserver: detects when tab becomes visible (0×0 → real size)
const resizeObserver = new ResizeObserver(() => onResize());
resizeObserver.observe(el);
return () => {
controls.removeEventListener('change', onControlsChange);
el.removeEventListener('mousedown', onInteract);
el.removeEventListener('touchstart', onInteract);
el.removeEventListener('wheel', onInteract);
window.removeEventListener('resize', onResize);
resizeObserver.disconnect();
};
}, [places, pauseRotation, containerVisible]);
// ── Play handler ──
const handlePlay = useCallback(async (
stationId: string, stationName: string,
overridePlaceName?: string, overrideCountry?: string,
) => {
if (!selectedGuild || !selectedChannel) return;
setPlayingLoading(true);
try {
const res = await fetch('/api/radio/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guildId: selectedGuild,
voiceChannelId: selectedChannel,
stationId,
stationName,
placeName: overridePlaceName ?? selectedPlace?.title ?? '',
country: overrideCountry ?? selectedPlace?.country ?? '',
}),
});
const result = await res.json();
if (result.ok) {
setNowPlaying(prev => ({
...prev,
[selectedGuild]: {
stationId, stationName,
placeName: overridePlaceName ?? selectedPlace?.title ?? '',
country: overrideCountry ?? selectedPlace?.country ?? '',
startedAt: new Date().toISOString(),
channelName: guilds.find(g => g.id === selectedGuild)
?.voiceChannels.find(c => c.id === selectedChannel)?.name ?? '',
},
}));
pauseRotation();
}
} catch (e) { console.error(e); }
setPlayingLoading(false);
}, [selectedGuild, selectedChannel, selectedPlace, guilds]);
// ── Stop handler ──
const handleStop = useCallback(async () => {
if (!selectedGuild) return;
await fetch('/api/radio/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: selectedGuild }),
});
setNowPlaying(prev => {
const next = { ...prev };
delete next[selectedGuild];
return next;
});
}, [selectedGuild]);
// ── Search handler ──
const handleSearch = useCallback((q: string) => {
setSearchQuery(q);
if (searchTimeout.current) clearTimeout(searchTimeout.current);
if (!q.trim()) { setSearchResults([]); setSearchOpen(false); return; }
searchTimeout.current = setTimeout(async () => {
try {
const res = await fetch(`/api/radio/search?q=${encodeURIComponent(q)}`);
const results: SearchHit[] = await res.json();
setSearchResults(results);
setSearchOpen(true);
} catch { setSearchResults([]); }
}, 350);
}, []);
// ── Search result click ──
const handleSearchResultClick = useCallback((hit: SearchHit) => {
setSearchOpen(false);
setSearchQuery('');
setSearchResults([]);
if (hit.type === 'channel') {
const channelId = hit.url.match(/\/listen\/[^/]+\/([^/]+)/)?.[1];
if (channelId) {
handlePlay(channelId, hit.title, hit.subtitle, '');
}
} else if (hit.type === 'place') {
const placeId = hit.url.match(/\/visit\/[^/]+\/([^/]+)/)?.[1];
const place = places.find(p => p.id === placeId);
if (place) handlePointClickRef.current?.(place);
}
}, [places, handlePlay]);
// ── Favorite toggle ──
const toggleFavorite = useCallback(async (stationId: string, stationName: string) => {
try {
const res = await fetch('/api/radio/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stationId, stationName,
placeName: selectedPlace?.title ?? '',
country: selectedPlace?.country ?? '',
placeId: selectedPlace?.id ?? '',
}),
});
const result = await res.json();
if (result.favorites) setFavorites(result.favorites);
} catch {}
}, [selectedPlace]);
// ── Volume handler (debounced) ──
const handleVolume = useCallback((val: number) => {
setVolume(val);
if (!selectedGuild) return;
if (volumeTimeout.current) clearTimeout(volumeTimeout.current);
volumeTimeout.current = setTimeout(() => {
fetch('/api/radio/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: selectedGuild, volume: val }),
}).catch(console.error);
}, 100);
}, [selectedGuild]);
const isFavorite = (stationId: string) => favorites.some(f => f.stationId === stationId);
const currentPlaying = selectedGuild ? nowPlaying[selectedGuild] : null;
const currentGuild = guilds.find(g => g.id === selectedGuild);
return (
<div className="radio-container" data-theme={theme}>
{/* ═══ TOPBAR ═══ */}
<header className="radio-topbar">
<div className="radio-topbar-left">
<span className="radio-topbar-logo">{'\u{1F30D}'}</span>
<span className="radio-topbar-title">World Radio</span>
{guilds.length > 1 && (
<select className="radio-sel" value={selectedGuild} onChange={e => {
setSelectedGuild(e.target.value);
const g = guilds.find(x => x.id === e.target.value);
const ch = g?.voiceChannels.find(c => c.members > 0) ?? g?.voiceChannels[0];
setSelectedChannel(ch?.id ?? '');
}}>
{guilds.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
)}
<select className="radio-sel" value={selectedChannel} onChange={e => setSelectedChannel(e.target.value)}>
<option value="">Voice Channel...</option>
{currentGuild?.voiceChannels.map(c => (
<option key={c.id} value={c.id}>{'\u{1F50A}'} {c.name}{c.members > 0 ? ` (${c.members})` : ''}</option>
))}
</select>
</div>
{currentPlaying && (
<div className="radio-topbar-np">
<div className="radio-eq radio-eq-np"><span /><span /><span /></div>
<div className="radio-np-info">
<span className="radio-np-name">{currentPlaying.stationName}</span>
<span className="radio-np-loc">{currentPlaying.placeName}{currentPlaying.country ? `, ${currentPlaying.country}` : ''}</span>
</div>
</div>
)}
<div className="radio-topbar-right">
{currentPlaying && (
<>
<div className="radio-volume">
<span className="radio-volume-icon">{volume === 0 ? '\u{1F507}' : volume < 0.4 ? '\u{1F509}' : '\u{1F50A}'}</span>
<input
type="range"
className="radio-volume-slider"
min={0} max={1} step={0.01}
value={volume}
onChange={e => handleVolume(Number(e.target.value))}
/>
<span className="radio-volume-val">{Math.round(volume * 100)}%</span>
</div>
<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-topbar-stop" onClick={handleStop}>{'\u23F9'} Stop</button>
</>
)}
<div className="radio-theme-inline">
{THEMES.map(t => (
<div
key={t.id}
className={`radio-theme-dot ${theme === t.id ? 'active' : ''}`}
style={{ background: t.color }}
title={t.label}
onClick={() => setTheme(t.id)}
/>
))}
</div>
</div>
</header>
{/* ═══ GLOBE AREA ═══ */}
<div className="radio-globe-wrap">
<div className="radio-globe" ref={containerRef} />
{/* ── Search ── */}
<div className="radio-search">
<div className="radio-search-wrap">
<span className="radio-search-icon">{'\u{1F50D}'}</span>
<input
className="radio-search-input"
type="text"
placeholder="Sender oder Stadt suchen..."
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
onFocus={() => { if (searchResults.length) setSearchOpen(true); }}
/>
{searchQuery && (
<button className="radio-search-clear" onClick={() => { setSearchQuery(''); setSearchResults([]); setSearchOpen(false); }}>{'\u2715'}</button>
)}
</div>
{searchOpen && searchResults.length > 0 && (
<div className="radio-search-results">
{searchResults.slice(0, 12).map(hit => (
<button key={hit.id + hit.url} className="radio-search-result" onClick={() => handleSearchResultClick(hit)}>
<span className="radio-search-result-icon">
{hit.type === 'channel' ? '\u{1F4FB}' : hit.type === 'place' ? '\u{1F4CD}' : '\u{1F30D}'}
</span>
<div className="radio-search-result-text">
<span className="radio-search-result-title">{hit.title}</span>
<span className="radio-search-result-sub">{hit.subtitle}</span>
</div>
</button>
))}
</div>
)}
</div>
{/* ── Favorites toggle (hidden when any panel is open) ── */}
{!selectedPlace && !showFavorites && (
<button
className="radio-fab"
onClick={() => { setShowFavorites(true); setSelectedPlace(null); }}
title="Favoriten"
>
{'\u2B50'}{favorites.length > 0 && <span className="radio-fab-badge">{favorites.length}</span>}
</button>
)}
{/* ── Side Panel: Favorites ── */}
{showFavorites && (
<div className="radio-panel open">
<div className="radio-panel-header">
<h3>{'\u2B50'} Favoriten</h3>
<button className="radio-panel-close" onClick={() => setShowFavorites(false)}>{'\u2715'}</button>
</div>
<div className="radio-panel-body">
{favorites.length === 0 ? (
<div className="radio-panel-empty">Noch keine Favoriten</div>
) : (
favorites.map(fav => (
<div key={fav.stationId} className={`radio-station ${currentPlaying?.stationId === fav.stationId ? 'playing' : ''}`}>
<div className="radio-station-info">
<span className="radio-station-name">{fav.stationName}</span>
<span className="radio-station-loc">{fav.placeName}, {fav.country}</span>
</div>
<div className="radio-station-btns">
<button
className="radio-btn-play"
onClick={() => handlePlay(fav.stationId, fav.stationName, fav.placeName, fav.country)}
disabled={!selectedChannel || playingLoading}
>{'\u25B6'}</button>
<button className="radio-btn-fav active" onClick={() => toggleFavorite(fav.stationId, fav.stationName)}>{'\u2605'}</button>
</div>
</div>
))
)}
</div>
</div>
)}
{/* ── Side Panel: Stations at place ── */}
{selectedPlace && !showFavorites && (
<div className="radio-panel open">
<div className="radio-panel-header">
<div>
<h3>{selectedPlace.title}</h3>
<span className="radio-panel-sub">{selectedPlace.country}</span>
</div>
<button className="radio-panel-close" onClick={() => setSelectedPlace(null)}>{'\u2715'}</button>
</div>
<div className="radio-panel-body">
{stationsLoading ? (
<div className="radio-panel-loading">
<div className="radio-spinner" />
Sender werden geladen...
</div>
) : stations.length === 0 ? (
<div className="radio-panel-empty">Keine Sender gefunden</div>
) : (
stations.map(s => (
<div key={s.id} className={`radio-station ${currentPlaying?.stationId === s.id ? 'playing' : ''}`}>
<div className="radio-station-info">
<span className="radio-station-name">{s.title}</span>
{currentPlaying?.stationId === s.id && (
<span className="radio-station-live">
<span className="radio-eq"><span /><span /><span /></span>
Live
</span>
)}
</div>
<div className="radio-station-btns">
{currentPlaying?.stationId === s.id ? (
<button className="radio-btn-stop" onClick={handleStop}>{'\u23F9'}</button>
) : (
<button
className="radio-btn-play"
onClick={() => handlePlay(s.id, s.title)}
disabled={!selectedChannel || playingLoading}
>{'\u25B6'}</button>
)}
<button
className={`radio-btn-fav ${isFavorite(s.id) ? 'active' : ''}`}
onClick={() => toggleFavorite(s.id, s.title)}
>{isFavorite(s.id) ? '\u2605' : '\u2606'}</button>
</div>
</div>
))
)}
</div>
</div>
)}
{/* ── Places counter ── */}
<div className="radio-counter">
{'\u{1F4FB}'} {places.length.toLocaleString('de-DE')} Sender weltweit
</div>
<a
className="radio-attribution"
href="https://science.nasa.gov/earth/earth-observatory/blue-marble-next-generation/"
target="_blank"
rel="noreferrer"
>
Imagery © NASA Blue Marble
</a>
</div>
{/* ── Connection Details Modal ── */}
{showConnModal && (() => {
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 ?? null) }} />
{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 ?? null) }} />
{voiceStats && 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 ?? 'Warte auf Verbindung'}
</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>
);
}