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; } 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' }, ]; // ── 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 searchTimeout = useRef>(undefined); const volumeTimeout = 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?.guildId && 'playing' in data) { // 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) { // 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); } }, [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 ── const pauseRotation = useCallback(() => { const controls = globeRef.current?.controls() as any; if (controls) controls.autoRotate = false; if (rotationResumeRef.current) clearTimeout(rotationResumeRef.current); rotationResumeRef.current = setTimeout(() => { const c = globeRef.current?.controls() as any; if (c) c.autoRotate = true; }, 5000); }, []); // ── 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)); }; // ── Initialize globe ── useEffect(() => { if (!containerRef.current || places.length === 0) return; if (globeRef.current) { globeRef.current.pointsData(places); return; } // Read accent color from theme const initStyle = getComputedStyle(containerRef.current.parentElement!); const initRgb = initStyle.getPropertyValue('--accent-rgb').trim() || '230, 126, 34'; const initAccent = initStyle.getPropertyValue('--accent').trim() || '#e67e22'; const globe = new Globe(containerRef.current) .globeImageUrl('//unpkg.com/three-globe/example/img/earth-night.jpg') .backgroundColor('rgba(0,0,0,0)') .atmosphereColor(`rgba(${initRgb}, 0.25)`) .atmosphereAltitude(0.12) .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.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; } // Scale point radius based on zoom altitude so points don't overlap when zoomed in let scaleRafId = 0; const BASE_ALT = 2.0; // default altitude const onControlsChange = () => { cancelAnimationFrame(scaleRafId); scaleRafId = requestAnimationFrame(() => { const alt = globe.pointOfView().altitude; const scale = Math.max(0.1, 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 * 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) { globeRef.current .width(containerRef.current.clientWidth) .height(containerRef.current.clientHeight); } }; window.addEventListener('resize', onResize); return () => { controls.removeEventListener('change', onControlsChange); cancelAnimationFrame(scaleRafId); el.removeEventListener('mousedown', onInteract); el.removeEventListener('touchstart', onInteract); el.removeEventListener('wheel', onInteract); window.removeEventListener('resize', onResize); }; }, [places, pauseRotation]); // ── 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 (
{/* ── Globe ── */}
{/* ── Theme Selector ── */}
{THEMES.map(t => (
setTheme(t.id)} /> ))}
{/* ── 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}` : ''}
{volume === 0 ? '\u{1F507}' : volume < 0.4 ? '\u{1F509}' : '\u{1F50A}'} handleVolume(Number(e.target.value))} /> {Math.round(volume * 100)}%
{'\u{1F50A}'} {currentPlaying.channelName}
)}
{/* ── Places counter ── */}
{'\u{1F4FB}'} {places.length.toLocaleString('de-DE')} Sender weltweit
); }