diff --git a/server/src/index.ts b/server/src/index.ts index 5945380..cd78f9b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import client from './core/discord.js'; import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; import { loadState, getFullState } from './core/persistence.js'; import { getPlugins, registerPlugin, PluginContext } from './core/plugin.js'; +import radioPlugin from './plugins/radio/index.js'; // ── Config ── const PORT = Number(process.env.PORT ?? 8080); @@ -68,10 +69,7 @@ app.get('/api/plugins', (_req, res) => { }))); }); -// ── SPA Fallback ── -app.get('*', (_req, res) => { - res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html')); -}); +// NOTE: SPA fallback is added in boot() AFTER plugin routes // ── Discord Ready ── client.once('ready', async () => { @@ -87,8 +85,8 @@ client.once('ready', async () => { // ── Init Plugins ── async function boot(): Promise { - // --- Load plugins dynamically here --- - // Example: import('./plugins/soundboard/index.js').then(m => registerPlugin(m.default)); + // ── Register plugins ── + registerPlugin(radioPlugin); // Init all plugins for (const p of getPlugins()) { @@ -101,6 +99,11 @@ async function boot(): Promise { } } + // SPA Fallback (MUST be after plugin routes) + app.get('*', (_req, res) => { + res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html')); + }); + // Start Express app.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`)); diff --git a/server/src/plugins/radio/api.ts b/server/src/plugins/radio/api.ts new file mode 100644 index 0000000..1111058 --- /dev/null +++ b/server/src/plugins/radio/api.ts @@ -0,0 +1,104 @@ +// ── Radio Garden API Client ── + +const BASE = 'https://radio.garden/api'; +const UA = 'GamingHub/1.0'; + +export interface RadioPlace { + id: string; + geo: [number, number]; + title: string; + country: string; + size: number; +} + +export interface RadioChannel { + id: string; + title: string; +} + +export interface SearchHit { + id: string; + type: string; + title: string; + subtitle: string; + url: string; +} + +// ── Cache ── +let placesCache: RadioPlace[] = []; +let placesCacheTime = 0; +const PLACES_TTL = 24 * 60 * 60 * 1000; // 24h + +// ── Helpers ── +async function apiFetch(path: string): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'User-Agent': UA }, + }); + if (!res.ok) throw new Error(`Radio Garden API ${res.status}: ${path}`); + return res.json(); +} + +// ── Public API ── + +/** Alle Orte mit Radiosendern weltweit (~30K Einträge, gecached 24h) */ +export async function fetchPlaces(): Promise { + if (placesCache.length > 0 && Date.now() - placesCacheTime < PLACES_TTL) { + return placesCache; + } + const data = await apiFetch('/ara/content/places'); + placesCache = (data?.data?.list ?? []).map((p: any) => ({ + id: p.id, + geo: p.geo, + title: p.title, + country: p.country, + size: p.size ?? 1, + })); + placesCacheTime = Date.now(); + console.log(`[Radio] Fetched ${placesCache.length} places from Radio Garden`); + return placesCache; +} + +/** Sender an einem bestimmten Ort */ +export async function fetchPlaceChannels(placeId: string): Promise { + const data = await apiFetch(`/ara/content/page/${placeId}/channels`); + const channels: RadioChannel[] = []; + for (const section of data?.data?.content ?? []) { + for (const item of section.items ?? []) { + const href: string = item.href ?? item.page?.url ?? ''; + const match = href.match(/\/listen\/([^/]+)/); + if (match) { + channels.push({ id: match[1], title: item.title ?? 'Unbekannt' }); + } + } + } + return channels; +} + +/** Sender/Orte/Länder suchen */ +export async function searchStations(query: string): Promise { + const data = await apiFetch(`/search?q=${encodeURIComponent(query)}`); + return (data?.hits?.hits ?? []).map((h: any) => ({ + id: h._id ?? '', + type: h._source?.type ?? 'unknown', + title: h._source?.title ?? '', + subtitle: h._source?.subtitle ?? '', + url: h._source?.url ?? '', + })); +} + +/** Stream-URL auflösen (302 Redirect → tatsächliche Icecast/Shoutcast URL) */ +export async function resolveStreamUrl(channelId: string): Promise { + try { + const res = await fetch(`${BASE}/ara/content/listen/${channelId}/channel.mp3`, { + redirect: 'manual', + headers: { 'User-Agent': UA }, + }); + return res.headers.get('location') ?? null; + } catch { + return null; + } +} + +export function getPlacesCount(): number { + return placesCache.length; +} diff --git a/server/src/plugins/radio/index.ts b/server/src/plugins/radio/index.ts new file mode 100644 index 0000000..2739295 --- /dev/null +++ b/server/src/plugins/radio/index.ts @@ -0,0 +1,329 @@ +import type express from 'express'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { + joinVoiceChannel, createAudioPlayer, createAudioResource, + VoiceConnectionStatus, StreamType, getVoiceConnection, entersState, +} from '@discordjs/voice'; +import type { VoiceBasedChannel } from 'discord.js'; +import { ChannelType } from 'discord.js'; +import type { Plugin, PluginContext } from '../../core/plugin.js'; +import { sseBroadcast } from '../../core/sse.js'; +import { getState, setState } from '../../core/persistence.js'; +import { + fetchPlaces, fetchPlaceChannels, searchStations, + resolveStreamUrl, getPlacesCount, +} from './api.js'; + +// ── Types ── +interface GuildRadioState { + stationId: string; + stationName: string; + placeName: string; + country: string; + streamUrl: string; + startedAt: string; + ffmpeg: ChildProcess; + player: ReturnType; + channelId: string; + channelName: string; +} + +interface Favorite { + stationId: string; + stationName: string; + placeName: string; + country: string; + placeId: string; +} + +// ── State ── +const guildRadioState = new Map(); + +function getFavorites(): Favorite[] { + return getState('radio_favorites', []); +} + +function setFavorites(favs: Favorite[]): void { + setState('radio_favorites', favs); +} + +// ── Streaming ── +function stopStream(guildId: string): void { + const state = guildRadioState.get(guildId); + if (!state) return; + try { state.ffmpeg.kill('SIGKILL'); } catch {} + try { state.player.stop(true); } catch {} + try { getVoiceConnection(guildId)?.destroy(); } catch {} + guildRadioState.delete(guildId); + broadcastState(guildId); + console.log(`[Radio] Stopped stream in guild ${guildId}`); +} + +async function startStream( + ctx: PluginContext, guildId: string, voiceChannelId: string, + stationId: string, stationName: string, placeName: string, country: string, +): Promise<{ ok: boolean; error?: string }> { + // Stoppe laufenden Stream in diesem Guild + stopStream(guildId); + + // Stream-URL auflösen + const streamUrl = await resolveStreamUrl(stationId); + if (!streamUrl) return { ok: false, error: 'Stream-URL konnte nicht aufgelöst werden' }; + + // Guild + Channel finden + const guild = ctx.client.guilds.cache.get(guildId); + if (!guild) return { ok: false, error: 'Guild nicht gefunden' }; + const channel = guild.channels.cache.get(voiceChannelId) as VoiceBasedChannel | undefined; + if (!channel) return { ok: false, error: 'Voice Channel nicht gefunden' }; + + // Voice-Channel joinen + const connection = joinVoiceChannel({ + channelId: voiceChannelId, + guildId, + adapterCreator: guild.voiceAdapterCreator, + selfDeaf: true, + }); + + try { + await entersState(connection, VoiceConnectionStatus.Ready, 10_000); + } catch { + connection.destroy(); + return { ok: false, error: 'Voice-Verbindung fehlgeschlagen' }; + } + + // ffmpeg spawnen – Radio-Stream → raw PCM + const ffmpeg = spawn('ffmpeg', [ + '-reconnect', '1', + '-reconnect_streamed', '1', + '-reconnect_delay_max', '5', + '-i', streamUrl, + '-f', 's16le', + '-ar', '48000', + '-ac', '2', + '-loglevel', 'error', + 'pipe:1', + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + ffmpeg.stderr?.on('data', (d: Buffer) => { + const msg = d.toString().trim(); + if (msg) console.error(`[Radio:ffmpeg] ${msg}`); + }); + + ffmpeg.on('close', (code) => { + if (guildRadioState.has(guildId)) { + console.log(`[Radio] ffmpeg exited (code ${code}), cleaning up`); + guildRadioState.delete(guildId); + try { connection.destroy(); } catch {} + broadcastState(guildId); + } + }); + + // AudioResource + Player + const resource = createAudioResource(ffmpeg.stdout!, { + inputType: StreamType.Raw, + }); + + const player = createAudioPlayer(); + player.play(resource); + connection.subscribe(player); + + player.on('error', (err) => { + console.error(`[Radio] Player error:`, err.message); + stopStream(guildId); + }); + + // State tracken + const channelName = 'name' in channel ? (channel as any).name : voiceChannelId; + guildRadioState.set(guildId, { + stationId, stationName, placeName, country, + streamUrl, startedAt: new Date().toISOString(), + ffmpeg, player, channelId: voiceChannelId, channelName, + }); + + broadcastState(guildId); + console.log(`[Radio] ▶ "${stationName}" (${placeName}, ${country}) → ${guild.name}/#${channelName}`); + return { ok: true }; +} + +function broadcastState(guildId: string): void { + const state = guildRadioState.get(guildId); + sseBroadcast({ + type: 'radio', + plugin: 'radio', + guildId, + playing: state ? { + stationId: state.stationId, + stationName: state.stationName, + placeName: state.placeName, + country: state.country, + startedAt: state.startedAt, + channelName: state.channelName, + } : null, + }); +} + +// ── Plugin ── +const radioPlugin: Plugin = { + name: 'radio', + version: '1.0.0', + description: 'World Radio – Radiosender aus aller Welt streamen', + + async init() { + fetchPlaces() + .then(p => console.log(`[Radio] ${p.length} Orte gecached`)) + .catch(e => console.error('[Radio] Places-Fetch fehlgeschlagen:', e)); + }, + + async onReady(ctx) { + console.log(`[Radio] Discord ready – ${ctx.client.guilds.cache.size} Guild(s)`); + }, + + registerRoutes(app: express.Application, ctx: PluginContext) { + + // ── Alle Orte (für Globe) ── + app.get('/api/radio/places', async (_req, res) => { + try { + const places = await fetchPlaces(); + res.json(places); + } catch (e: any) { + res.status(502).json({ error: e.message }); + } + }); + + // ── Sender an einem Ort ── + app.get('/api/radio/place/:id/channels', async (req, res) => { + try { + const channels = await fetchPlaceChannels(req.params.id); + res.json(channels); + } catch (e: any) { + res.status(502).json({ error: e.message }); + } + }); + + // ── Suche ── + app.get('/api/radio/search', async (req, res) => { + const q = (req.query.q as string) ?? ''; + if (!q.trim()) return res.json([]); + try { + const results = await searchStations(q); + res.json(results); + } catch (e: any) { + res.status(502).json({ error: e.message }); + } + }); + + // ── Verfügbare Guilds + Voice Channels ── + app.get('/api/radio/guilds', (_req, res) => { + const guilds = ctx.client.guilds.cache.map(g => ({ + id: g.id, + name: g.name, + icon: g.iconURL({ size: 64 }), + voiceChannels: g.channels.cache + .filter(c => c.type === ChannelType.GuildVoice || c.type === ChannelType.GuildStageVoice) + .map(c => ({ + id: c.id, + name: c.name, + members: ('members' in c) + ? (c as VoiceBasedChannel).members.filter(m => !m.user.bot).size + : 0, + })), + })); + res.json(guilds); + }); + + // ── Play ── + app.post('/api/radio/play', async (req, res) => { + const { guildId, voiceChannelId, stationId, stationName, placeName, country } = req.body ?? {}; + if (!guildId || !voiceChannelId || !stationId) { + return res.status(400).json({ error: 'guildId, voiceChannelId, stationId required' }); + } + const result = await startStream( + ctx, guildId, voiceChannelId, stationId, + stationName ?? '', placeName ?? '', country ?? '', + ); + res.json(result); + }); + + // ── Stop ── + app.post('/api/radio/stop', (req, res) => { + const { guildId } = req.body ?? {}; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + stopStream(guildId); + res.json({ ok: true }); + }); + + // ── Favoriten lesen ── + app.get('/api/radio/favorites', (_req, res) => { + res.json(getFavorites()); + }); + + // ── Favorit togglen ── + app.post('/api/radio/favorites', (req, res) => { + const { stationId, stationName, placeName, country, placeId } = req.body ?? {}; + if (!stationId) return res.status(400).json({ error: 'stationId required' }); + + const favs = getFavorites(); + const idx = favs.findIndex(f => f.stationId === stationId); + if (idx >= 0) { + favs.splice(idx, 1); + } else { + favs.push({ + stationId, + stationName: stationName ?? '', + placeName: placeName ?? '', + country: country ?? '', + placeId: placeId ?? '', + }); + } + setFavorites(favs); + sseBroadcast({ type: 'radio_favorites', plugin: 'radio', favorites: favs }); + res.json({ favorites: favs }); + }); + + // ── Status ── + app.get('/api/radio/status', (_req, res) => { + const status: Record = {}; + for (const [gId, st] of guildRadioState) { + status[gId] = { + stationId: st.stationId, + stationName: st.stationName, + placeName: st.placeName, + country: st.country, + startedAt: st.startedAt, + channelName: st.channelName, + }; + } + res.json(status); + }); + }, + + getSnapshot() { + const playing: Record = {}; + for (const [gId, st] of guildRadioState) { + playing[gId] = { + stationId: st.stationId, + stationName: st.stationName, + placeName: st.placeName, + country: st.country, + startedAt: st.startedAt, + channelName: st.channelName, + }; + } + return { + radio: { + playing, + favorites: getFavorites(), + placesCount: getPlacesCount(), + }, + }; + }, + + async destroy() { + for (const guildId of guildRadioState.keys()) { + stopStream(guildId); + } + console.log('[Radio] Destroyed'); + }, +}; + +export default radioPlugin; diff --git a/web/package.json b/web/package.json index 8b7260d..f5bf784 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,9 @@ }, "dependencies": { "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "globe.gl": "^2.35.0", + "three": "^0.172.0" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 3126619..a55c400 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import RadioTab from './plugins/radio/RadioTab'; interface PluginInfo { name: string; @@ -6,8 +7,10 @@ interface PluginInfo { description: string; } -// Plugin tab components will be registered here -const tabComponents: Record> = {}; +// Plugin tab components +const tabComponents: Record> = { + radio: RadioTab, +}; export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { tabComponents[pluginName] = component; @@ -72,6 +75,7 @@ export default function App() { // Tab icon mapping const tabIcons: Record = { + radio: '\u{1F30D}', soundboard: '\u{1F3B5}', stats: '\u{1F4CA}', events: '\u{1F4C5}', diff --git a/web/src/plugins/radio/RadioTab.tsx b/web/src/plugins/radio/RadioTab.tsx new file mode 100644 index 0000000..0592732 --- /dev/null +++ b/web/src/plugins/radio/RadioTab.tsx @@ -0,0 +1,448 @@ +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>(); + + // ── 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>(); + 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 = 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 +
+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index c71d5e1..f054217 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -348,3 +348,591 @@ html, body { font-size: 14px; } } + +/* ══════════════════════════════════════════════ + RADIO PLUGIN – World Radio Globe + ══════════════════════════════════════════════ */ + +.radio-container { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--bg-deep); +} + +/* ── Globe ── */ +.radio-globe { + width: 100%; + height: 100%; +} + +.radio-globe canvas { + outline: none !important; +} + +/* ── Search Overlay ── */ +.radio-search { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 20; + width: min(440px, calc(100% - 32px)); +} + +.radio-search-wrap { + display: flex; + align-items: center; + background: rgba(30, 31, 34, 0.92); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 0 14px; + gap: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); +} + +.radio-search-icon { + font-size: 16px; + opacity: 0.6; + flex-shrink: 0; +} + +.radio-search-input { + flex: 1; + background: transparent; + border: none; + color: var(--text-normal); + font-family: var(--font); + font-size: 14px; + padding: 12px 0; + outline: none; +} + +.radio-search-input::placeholder { + color: var(--text-faint); +} + +.radio-search-clear { + background: none; + border: none; + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: color var(--transition); +} + +.radio-search-clear:hover { + color: var(--text-normal); +} + +/* ── Search Results ── */ +.radio-search-results { + margin-top: 6px; + background: rgba(30, 31, 34, 0.95); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + max-height: 360px; + overflow-y: auto; + box-shadow: 0 12px 40px rgba(0,0,0,0.5); + scrollbar-width: thin; + scrollbar-color: var(--bg-tertiary) transparent; +} + +.radio-search-result { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + background: none; + border: none; + border-bottom: 1px solid var(--border); + color: var(--text-normal); + font-family: var(--font); + font-size: 14px; + cursor: pointer; + text-align: left; + transition: background var(--transition); +} + +.radio-search-result:last-child { + border-bottom: none; +} + +.radio-search-result:hover { + background: rgba(var(--accent-rgb), 0.08); +} + +.radio-search-result-icon { + font-size: 18px; + flex-shrink: 0; +} + +.radio-search-result-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.radio-search-result-title { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.radio-search-result-sub { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Favorites FAB ── */ +.radio-fab { + position: absolute; + top: 16px; + right: 16px; + z-index: 20; + display: flex; + align-items: center; + gap: 4px; + padding: 10px 14px; + background: rgba(30, 31, 34, 0.92); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + color: var(--text-normal); + font-size: 16px; + cursor: pointer; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + transition: all var(--transition); +} + +.radio-fab:hover, +.radio-fab.active { + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.3); +} + +.radio-fab-badge { + font-size: 11px; + font-weight: 700; + background: var(--accent); + color: #fff; + padding: 1px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; +} + +/* ── Side Panel ── */ +.radio-panel { + position: absolute; + top: 0; + right: 0; + width: 340px; + height: 100%; + z-index: 15; + background: rgba(30, 31, 34, 0.95); + backdrop-filter: blur(16px); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + animation: slide-in-right 200ms ease; + box-shadow: -8px 0 32px rgba(0,0,0,0.3); +} + +@keyframes slide-in-right { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +.radio-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.radio-panel-header h3 { + font-size: 16px; + font-weight: 700; + color: var(--text-normal); +} + +.radio-panel-sub { + font-size: 12px; + color: var(--text-muted); + display: block; + margin-top: 2px; +} + +.radio-panel-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 18px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: all var(--transition); +} + +.radio-panel-close:hover { + color: var(--text-normal); + background: var(--bg-secondary); +} + +.radio-panel-body { + flex: 1; + overflow-y: auto; + padding: 8px; + scrollbar-width: thin; + scrollbar-color: var(--bg-tertiary) transparent; +} + +.radio-panel-empty { + text-align: center; + color: var(--text-muted); + padding: 40px 16px; + font-size: 14px; +} + +.radio-panel-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 40px 16px; + color: var(--text-muted); + font-size: 14px; +} + +/* ── Station Card ── */ +.radio-station { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: var(--radius); + transition: background var(--transition); + gap: 10px; +} + +.radio-station:hover { + background: var(--bg-secondary); +} + +.radio-station.playing { + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.2); +} + +.radio-station-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.radio-station-name { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.radio-station-loc { + font-size: 11px; + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.radio-station-live { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--accent); + font-weight: 600; +} + +.radio-station-btns { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* ── Buttons ── */ +.radio-btn-play, +.radio-btn-stop { + width: 34px; + height: 34px; + border: none; + border-radius: 50%; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); +} + +.radio-btn-play { + background: var(--accent); + color: #fff; +} + +.radio-btn-play:hover:not(:disabled) { + background: var(--accent-hover); + transform: scale(1.05); +} + +.radio-btn-play:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.radio-btn-stop { + background: var(--danger); + color: #fff; +} + +.radio-btn-stop:hover { + background: #c63639; +} + +.radio-btn-fav { + width: 34px; + height: 34px; + border: none; + border-radius: 50%; + font-size: 16px; + cursor: pointer; + background: transparent; + color: var(--text-faint); + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); +} + +.radio-btn-fav:hover { + color: var(--warning); + background: rgba(254, 231, 92, 0.1); +} + +.radio-btn-fav.active { + color: var(--warning); +} + +/* ── Equalizer Animation ── */ +.radio-eq { + display: flex; + align-items: flex-end; + gap: 2px; + height: 14px; +} + +.radio-eq span { + width: 3px; + background: var(--accent); + border-radius: 1px; + animation: eq-bounce 0.8s ease-in-out infinite; +} + +.radio-eq span:nth-child(1) { height: 8px; animation-delay: 0s; } +.radio-eq span:nth-child(2) { height: 14px; animation-delay: 0.15s; } +.radio-eq span:nth-child(3) { height: 10px; animation-delay: 0.3s; } + +@keyframes eq-bounce { + 0%, 100% { transform: scaleY(0.4); } + 50% { transform: scaleY(1); } +} + +/* ── Bottom Bar ── */ +.radio-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 20; + background: rgba(30, 31, 34, 0.95); + backdrop-filter: blur(16px); + border-top: 1px solid var(--border); + padding: 10px 16px; + display: flex; + align-items: center; + gap: 16px; + box-shadow: 0 -4px 24px rgba(0,0,0,0.3); +} + +.radio-bar-channel { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.radio-sel { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-normal); + font-family: var(--font); + font-size: 13px; + padding: 6px 10px; + cursor: pointer; + outline: none; + max-width: 180px; +} + +.radio-sel:focus { + border-color: var(--accent); +} + +/* ── Now Playing ── */ +.radio-np { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.radio-eq-np { + flex-shrink: 0; +} + +.radio-np-info { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} + +.radio-np-name { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.radio-np-loc { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.radio-np-ch { + font-size: 12px; + color: var(--text-faint); + white-space: nowrap; + flex-shrink: 0; +} + +.radio-bar .radio-btn-stop { + width: auto; + border-radius: var(--radius); + padding: 6px 14px; + font-size: 13px; + gap: 4px; + flex-shrink: 0; +} + +/* ── Station count ── */ +.radio-counter { + position: absolute; + bottom: 70px; + left: 16px; + z-index: 10; + font-size: 12px; + color: var(--text-faint); + background: rgba(30, 31, 34, 0.8); + padding: 4px 10px; + border-radius: 20px; + pointer-events: none; +} + +/* ── Spinner ── */ +.radio-spinner { + width: 24px; + height: 24px; + border: 3px solid var(--bg-tertiary); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── Radio Responsive ── */ +@media (max-width: 768px) { + .radio-panel { + width: 100%; + } + + .radio-fab { + top: 12px; + right: 12px; + padding: 8px 10px; + font-size: 14px; + } + + .radio-search { + top: 12px; + width: calc(100% - 80px); + left: calc(50% - 24px); + } + + .radio-bar { + flex-wrap: wrap; + padding: 8px 12px; + gap: 8px; + } + + .radio-sel { + max-width: 140px; + font-size: 12px; + } + + .radio-counter { + bottom: 62px; + left: 12px; + } +} + +@media (max-width: 480px) { + .radio-np-ch { + display: none; + } + + .radio-bar-channel { + flex-wrap: wrap; + } + + .radio-sel { + max-width: 120px; + } +}