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; } // ── Component ── export default function RadioTab({ data }: { data: any }) { const containerRef = useRef(null); const globeRef = useRef(null); 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 searchTimeout = useRef>(undefined); // ── 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); }, []); // ── Handle SSE data ── useEffect(() => { if (data?.playing) setNowPlaying(data.playing); if (data?.favorites) setFavorites(data.favorites); }, [data]); // ── Point click handler (stable ref) ── const handlePointClickRef = useRef<(point: any) => void>(undefined); handlePointClickRef.current = (point: any) => { setSelectedPlace(point); setShowFavorites(false); setStationsLoading(true); setStations([]); if (globeRef.current) { globeRef.current.pointOfView({ lat: point.geo[0], lng: point.geo[1], 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)); }; // ── Initialize globe ── useEffect(() => { if (!containerRef.current || places.length === 0) return; if (globeRef.current) { globeRef.current.pointsData(places); return; } const globe = new Globe(containerRef.current) .globeImageUrl('//unpkg.com/three-globe/example/img/earth-night.jpg') .backgroundColor('rgba(0,0,0,0)') .atmosphereColor('rgba(230, 126, 34, 0.25)') .atmosphereAltitude(0.12) .pointsData(places) .pointLat((d: any) => d.geo[0]) .pointLng((d: any) => d.geo[1]) .pointColor(() => 'rgba(230, 126, 34, 0.85)') .pointRadius((d: any) => Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005))) .pointAltitude(0.003) .pointLabel((d: any) => `
` + `${d.title}
${d.country}
` ) .onPointClick((d: any) => handlePointClickRef.current?.(d)) .width(containerRef.current.clientWidth) .height(containerRef.current.clientHeight); // Start-Position: Europa globe.pointOfView({ lat: 48, lng: 10, altitude: 2.0 }); // Auto-Rotation const controls = globe.controls() as any; if (controls) { controls.autoRotate = true; controls.autoRotateSpeed = 0.3; } globeRef.current = globe; const onResize = () => { if (containerRef.current && globeRef.current) { globeRef.current .width(containerRef.current.clientWidth) .height(containerRef.current.clientHeight); } }; window.addEventListener('resize', onResize); return () => { window.removeEventListener('resize', onResize); }; }, [places]); // ── 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 ?? '', }, })); // Stoppe Auto-Rotation beim Abspielen const controls = globeRef.current?.controls() as any; if (controls) controls.autoRotate = false; } } 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]); 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 (
{/* ── Globe ── */}
{/* ── 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 ── */} {/* ── 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 ? ( ) : ( )}
)) )}
)} {/* ── Bottom Bar ── */}
{guilds.length > 1 && ( )}
{currentPlaying && (
{currentPlaying.stationName} {currentPlaying.placeName}{currentPlaying.country ? `, ${currentPlaying.country}` : ''}
{'\u{1F50A}'} {currentPlaying.channelName}
)}
{/* ── Places counter ── */}
{'\u{1F4FB}'} {places.length.toLocaleString('de-DE')} Sender weltweit
); }