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(null); const globeRef = useRef(null); const rotationResumeRef = useRef>(undefined); const [theme, setTheme] = useState(() => localStorage.getItem('radio-theme') || 'default'); const [places, setPlaces] = useState([]); const [selectedPlace, setSelectedPlace] = useState(null); const [stations, setStations] = useState([]); const [stationsLoading, setStationsLoading] = useState(false); const [nowPlaying, setNowPlaying] = useState>({}); const [guilds, setGuilds] = useState([]); const [selectedGuild, setSelectedGuild] = useState(''); const [selectedChannel, setSelectedChannel] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searchOpen, setSearchOpen] = useState(false); const [favorites, setFavorites] = useState([]); const [showFavorites, setShowFavorites] = useState(false); const [playingLoading, setPlayingLoading] = useState(false); const [volume, setVolume] = useState(0.5); const [voiceStats, setVoiceStats] = useState(null); const [showConnModal, setShowConnModal] = useState(false); const [containerVisible, setContainerVisible] = useState(false); const searchTimeout = useRef>(undefined); const volumeTimeout = useRef>(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 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) => `
` + `${d.title}
${d.country}
` ) .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 (
{/* ═══ TOPBAR ═══ */}
{'\u{1F30D}'} World Radio {guilds.length > 1 && ( )}
{currentPlaying && (
{currentPlaying.stationName} {currentPlaying.placeName}{currentPlaying.country ? `, ${currentPlaying.country}` : ''}
)}
{currentPlaying && ( <>
{volume === 0 ? '\u{1F507}' : volume < 0.4 ? '\u{1F509}' : '\u{1F50A}'} handleVolume(Number(e.target.value))} /> {Math.round(volume * 100)}%
setShowConnModal(true)} title="Verbindungsdetails"> Verbunden {voiceStats?.voicePing != null && ( {voiceStats.voicePing}ms )}
)}
{THEMES.map(t => (
setTheme(t.id)} /> ))}
{/* ═══ GLOBE AREA ═══ */}
{/* ── Search ── */}
{'\u{1F50D}'} handleSearch(e.target.value)} onFocus={() => { if (searchResults.length) setSearchOpen(true); }} /> {searchQuery && ( )}
{searchOpen && searchResults.length > 0 && (
{searchResults.slice(0, 12).map(hit => ( ))}
)}
{/* ── Favorites toggle (hidden when any panel is open) ── */} {!selectedPlace && !showFavorites && ( )} {/* ── Side Panel: Favorites ── */} {showFavorites && (

{'\u2B50'} Favoriten

{favorites.length === 0 ? (
Noch keine Favoriten
) : ( favorites.map(fav => (
{fav.stationName} {fav.placeName}, {fav.country}
)) )}
)} {/* ── Side Panel: Stations at place ── */} {selectedPlace && !showFavorites && (

{selectedPlace.title}

{selectedPlace.country}
{stationsLoading ? (
Sender werden geladen...
) : stations.length === 0 ? (
Keine Sender gefunden
) : ( stations.map(s => (
{s.title} {currentPlaying?.stationId === s.id && ( Live )}
{currentPlaying?.stationId === s.id ? ( ) : ( )}
)) )}
)} {/* ── Places counter ── */}
{'\u{1F4FB}'} {places.length.toLocaleString('de-DE')} Sender weltweit
Imagery © NASA Blue Marble
{/* ── 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 (
setShowConnModal(false)}>
e.stopPropagation()}>
{'\u{1F4E1}'} Verbindungsdetails
Voice Ping {voiceStats?.voicePing != null ? `${voiceStats.voicePing} ms` : '---'}
Gateway Ping {voiceStats && voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'}
Status {voiceStats?.status === 'ready' ? 'Verbunden' : voiceStats?.status ?? 'Warte auf Verbindung'}
Kanal {voiceStats?.channelName || '---'}
Verbunden seit {uptimeStr || '---'}
); })()}
); }