import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { Cast, Plus, Trash2, X, Globe, Camera, Loader2, Search, Heart, Check } from 'lucide-react'; import { useDragSource, useDragDrop } from '../contexts/DragDropContext'; import { getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation, uploadRadioCoverArt, deleteRadioCoverArt, uploadRadioCoverArtBytes, searchRadioBrowser, getTopRadioStations, fetchUrlBytes, InternetRadioStation, RadioBrowserStation, buildCoverArtUrl, coverArtCacheKey, RADIO_PAGE_SIZE, } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import CachedImage from '../components/CachedImage'; import { invalidateCoverArt } from '../utils/imageCache'; import CustomSelect from '../components/CustomSelect'; import { useTranslation } from 'react-i18next'; import { open } from '@tauri-apps/plugin-shell'; import { showToast } from '../utils/toast'; export default function InternetRadio() { const { t } = useTranslation(); const playRadio = usePlayerStore(s => s.playRadio); const stop = usePlayerStore(s => s.stop); const currentRadio = usePlayerStore(s => s.currentRadio); const isPlaying = usePlayerStore(s => s.isPlaying); const [stations, setStations] = useState([]); const [loading, setLoading] = useState(true); const [deleteConfirmId, setDeleteConfirmId] = useState(null); // null = closed, 'new' = create modal, InternetRadioStation = edit modal const [modalStation, setModalStation] = useState(null); const [browseOpen, setBrowseOpen] = useState(false); const [sortBy, setSortBy] = useState<'manual' | 'az' | 'za' | 'newest'>('manual'); const [activeFilter, setActiveFilter] = useState('all'); const [activeLetter, setActiveLetter] = useState(null); const [favorites, setFavorites] = useState>(() => { try { return new Set(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]')); } catch { return new Set(); } }); const [manualOrder, setManualOrder] = useState([]); const [dragOver, setDragOver] = useState<{ id: string; side: 'before' | 'after' } | null>(null); useEffect(() => { getInternetRadioStations() .then(setStations) .catch(() => {}) .finally(() => setLoading(false)); }, []); const reload = async () => { const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]); setStations(list); }; // Merge saved manual order with current stations when stations change useEffect(() => { if (!stations.length) return; const saved: string[] = (() => { try { return JSON.parse(localStorage.getItem('psysonic_radio_order') ?? '[]'); } catch { return []; } })(); const currentIds = new Set(stations.map(s => s.id)); const merged = saved.filter((id: string) => currentIds.has(id)); stations.forEach(s => { if (!merged.includes(s.id)) merged.push(s.id); }); setManualOrder(merged); }, [stations]); const toggleFavorite = useCallback((id: string) => { setFavorites(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next])); return next; }); }, []); const handleReorder = useCallback((srcId: string, tgtId: string, side: 'before' | 'after') => { setManualOrder(prev => { const order = [...prev]; const si = order.indexOf(srcId); if (si === -1) return prev; order.splice(si, 1); // remove from original position const ti = order.indexOf(tgtId); // recalculate after removal if (ti === -1) return prev; const insertAt = side === 'before' ? ti : ti + 1; order.splice(insertAt, 0, srcId); localStorage.setItem('psysonic_radio_order', JSON.stringify(order)); return order; }); }, []); // After chip-filter + sort, but before alphabet filter — used to compute available letters const sortedFilteredStations = useMemo(() => { let list = [...stations]; if (activeFilter === 'favorites') list = list.filter(s => favorites.has(s.id)); if (sortBy === 'az') list.sort((a, b) => a.name.localeCompare(b.name)); else if (sortBy === 'za') list.sort((a, b) => b.name.localeCompare(a.name)); else if (sortBy === 'newest') list.reverse(); else { const orderMap = new Map(manualOrder.map((id, i) => [id, i])); list.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999)); } return list; }, [stations, activeFilter, favorites, sortBy, manualOrder]); const availableLetters = useMemo(() => { const set = new Set(); for (const s of sortedFilteredStations) { const ch = s.name.trim()[0]?.toUpperCase() ?? ''; if (ch >= 'A' && ch <= 'Z') set.add(ch); else if (ch) set.add('#'); } return set; }, [sortedFilteredStations]); const displayedStations = useMemo(() => { if (!activeLetter) return sortedFilteredStations; return sortedFilteredStations.filter(s => { const ch = s.name.trim()[0]?.toUpperCase() ?? ''; if (activeLetter === '#') return !(ch >= 'A' && ch <= 'Z'); return ch === activeLetter; }); }, [sortedFilteredStations, activeLetter]); const handleSave = async (opts: { name: string; streamUrl: string; homepageUrl: string; coverFile: File | null; coverRemoved: boolean; }) => { if (modalStation === 'new') { await createInternetRadioStation( opts.name.trim(), opts.streamUrl.trim(), opts.homepageUrl.trim() || undefined ); if (opts.coverFile) { // Reload first to get the new station's ID, then upload cover const updated = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]); const created = updated.find( s => s.name === opts.name.trim() && s.streamUrl === opts.streamUrl.trim() ); if (created) { try { await uploadRadioCoverArt(created.id, opts.coverFile); await invalidateCoverArt(`ra-${created.id}`); } catch (err) { showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error'); } // Reload again so coverArt field is picked up await reload(); } else { setStations(updated); } } else { await reload(); } } else { const id = (modalStation as InternetRadioStation).id; await updateInternetRadioStation( id, opts.name.trim(), opts.streamUrl.trim(), opts.homepageUrl.trim() || undefined ); if (opts.coverFile) { try { await uploadRadioCoverArt(id, opts.coverFile); await invalidateCoverArt(`ra-${id}`); } catch (err) { showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error'); } } else if (opts.coverRemoved) { await deleteRadioCoverArt(id).catch(() => {}); await invalidateCoverArt(`ra-${id}`); } await reload(); } setModalStation(null); }; const handleDelete = async (e: React.MouseEvent, s: InternetRadioStation) => { e.stopPropagation(); if (deleteConfirmId !== s.id) { setDeleteConfirmId(s.id); return; } if (currentRadio?.id === s.id) stop(); try { await deleteInternetRadioStation(s.id); setStations(prev => prev.filter(st => st.id !== s.id)); } catch {} setDeleteConfirmId(null); }; const handlePlay = (e: React.MouseEvent, s: InternetRadioStation) => { e.stopPropagation(); if (currentRadio?.id === s.id && isPlaying) { stop(); } else { playRadio(s); } }; if (loading) { return (
); } return (
{/* ── Header ── */}

{t('radio.title')}

{/* ── Toolbar + Grid ── */} {stations.length === 0 ? (
{t('radio.empty')}
) : ( <> { setActiveFilter(f); setActiveLetter(null); }} /> setActiveLetter(prev => prev === l ? null : l)} /> {displayedStations.length === 0 ? (
{t('radio.noFavorites')}
) : (
{displayedStations.map(s => ( handlePlay(e, s)} onDelete={e => handleDelete(e, s)} onEdit={() => setModalStation(s)} onFavoriteToggle={() => toggleFavorite(s.id)} onDragEnter={side => setDragOver({ id: s.id, side })} onDragLeave={() => setDragOver(prev => prev?.id === s.id ? null : prev)} onDropOnto={(srcId, side) => handleReorder(srcId, s.id, side)} onCardMouseLeave={() => { if (deleteConfirmId === s.id) setDeleteConfirmId(null); }} /> ))}
)} )} {/* ── Edit/Create Modal ── */} {modalStation !== null && ( setModalStation(null)} onSave={handleSave} /> )} {/* ── Directory Modal ── */} {browseOpen && ( setBrowseOpen(false)} onAdded={reload} /> )}
); } // ── Toolbar ─────────────────────────────────────────────────────────────────── interface RadioToolbarProps { sortBy: 'manual' | 'az' | 'za' | 'newest'; activeFilter: string; onSortChange: (s: 'manual' | 'az' | 'za' | 'newest') => void; onFilterChange: (f: string) => void; } function RadioToolbar({ sortBy, activeFilter, onSortChange, onFilterChange }: RadioToolbarProps) { const { t } = useTranslation(); const sortOptions = [ { value: 'manual', label: t('radio.sortManual') }, { value: 'az', label: t('radio.sortAZ') }, { value: 'za', label: t('radio.sortZA') }, { value: 'newest', label: t('radio.sortNewest') }, ]; return (
{(['all', 'favorites'] as const).map(f => ( ))}
onSortChange(v as RadioToolbarProps['sortBy'])} style={{ width: 'max-content', minWidth: 130, maxWidth: 220, flexShrink: 0 }} />
); } // ── Alphabet Filter Bar ──────────────────────────────────────────────────────── const ALPHABET_KEYS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))]; interface AlphabetFilterBarProps { activeLetter: string | null; availableLetters: Set; onSelect: (l: string) => void; } function AlphabetFilterBar({ activeLetter, availableLetters, onSelect }: AlphabetFilterBarProps) { return (
{ALPHABET_KEYS.map(l => { const available = availableLetters.has(l); const active = activeLetter === l; return ( ); })}
); } // ── Radio Card ──────────────────────────────────────────────────────────────── interface RadioCardProps { s: InternetRadioStation; isActive: boolean; isPlaying: boolean; deleteConfirmId: string | null; isFavorite: boolean; isManual: boolean; dropIndicator: 'before' | 'after' | null; onPlay: (e: React.MouseEvent) => void; onDelete: (e: React.MouseEvent) => void; onEdit: () => void; onFavoriteToggle: () => void; onDragEnter: (side: 'before' | 'after') => void; onDragLeave: () => void; onDropOnto: (srcId: string, side: 'before' | 'after') => void; onCardMouseLeave: () => void; } function RadioCard({ s, isActive, isPlaying, deleteConfirmId, isFavorite, isManual, dropIndicator, onPlay, onDelete, onEdit, onFavoriteToggle, onDragEnter, onDragLeave, onDropOnto, onCardMouseLeave, }: RadioCardProps) { const { t } = useTranslation(); const cardRef = useRef(null); const lastSideRef = useRef<'before' | 'after'>('after'); const { isDragging, payload } = useDragDrop(); const isBeingDragged = isDragging && !!payload && (() => { try { return JSON.parse(payload.data).id === s.id; } catch { return false; } })(); const dragHandlers = useDragSource(() => ({ data: JSON.stringify({ type: 'radio', id: s.id }), label: s.name, })); // Calculate which half of the card the cursor is on const getSide = (e: React.MouseEvent): 'before' | 'after' => { const rect = cardRef.current?.getBoundingClientRect(); if (!rect) return 'after'; return e.clientX < rect.left + rect.width / 2 ? 'before' : 'after'; }; // psy-drop listener: fires when a drag is released over this card useEffect(() => { if (!isManual) return; const el = cardRef.current; if (!el) return; const handler = (e: Event) => { const data = JSON.parse((e as CustomEvent).detail?.data ?? '{}'); if (data.type === 'radio' && data.id !== s.id) onDropOnto(data.id, lastSideRef.current); }; el.addEventListener('psy-drop', handler); return () => el.removeEventListener('psy-drop', handler); }, [isManual, s.id, onDropOnto]); return (
{ if (!isDragging || !isManual) return; const side = getSide(e); lastSideRef.current = side; onDragEnter(side); }} onMouseLeave={() => { onDragLeave(); onCardMouseLeave(); }} > {/* Cover */}
{s.coverArt ? ( ) : (
)} {isActive && isPlaying && (
{t('radio.live')}
)}
{/* Info */}
{s.name}
{s.homepageUrl && ( )}
); } // ── Radio Edit Modal ────────────────────────────────────────────────────────── interface RadioEditModalProps { station: InternetRadioStation | null; // null = create new onClose: () => void; onSave: (opts: { name: string; streamUrl: string; homepageUrl: string; coverFile: File | null; coverRemoved: boolean; }) => Promise; } function RadioEditModal({ station, onClose, onSave }: RadioEditModalProps) { const { t } = useTranslation(); const [name, setName] = useState(station?.name ?? ''); const [streamUrl, setStreamUrl] = useState(station?.streamUrl ?? ''); const [homepageUrl, setHomepageUrl] = useState(station?.homepageUrl ?? ''); const [coverFile, setCoverFile] = useState(null); const [coverPreview, setCoverPreview] = useState(null); const [coverRemoved, setCoverRemoved] = useState(false); const [saving, setSaving] = useState(false); const coverInputRef = useRef(null); const hasExistingCover = !coverRemoved && (coverPreview || station?.coverArt); const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; setCoverFile(file); setCoverRemoved(false); const reader = new FileReader(); reader.onload = ev => setCoverPreview(ev.target?.result as string); reader.readAsDataURL(file); }; const handleRemoveCover = (e: React.MouseEvent) => { e.stopPropagation(); setCoverFile(null); setCoverPreview(null); setCoverRemoved(true); }; const handleSave = async () => { if (!name.trim() || !streamUrl.trim()) return; setSaving(true); try { await onSave({ name, streamUrl, homepageUrl, coverFile, coverRemoved }); } finally { setSaving(false); } }; const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) onClose(); }; return (
e.stopPropagation()} >

{station ? t('radio.editStation') : t('radio.addStation')}

{/* Cover + fields side by side */}
{/* Cover */}
coverInputRef.current?.click()} > {coverPreview ? ( ) : !coverRemoved && station?.coverArt ? ( ) : (
)}
{hasExistingCover && ( )}
{/* Fields */}
setName(e.target.value)} placeholder={t('radio.stationName')} autoFocus /> setStreamUrl(e.target.value)} placeholder={t('radio.streamUrl')} /> setHomepageUrl(e.target.value)} placeholder={t('radio.homepageUrl')} />
); } // ── Radio Directory Modal ───────────────────────────────────────────────────── interface RadioDirectoryModalProps { onClose: () => void; onAdded: () => void; } function RadioDirectoryModal({ onClose, onAdded }: RadioDirectoryModalProps) { const { t } = useTranslation(); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(true); const [addingId, setAddingId] = useState(null); const [addedIds, setAddedIds] = useState>(new Set()); const debounceRef = useRef | null>(null); const observerRef = useRef(null); const scrollContainerRef = useRef(null); const queryRef = useRef(query); useEffect(() => { queryRef.current = query; }, [query]); const fetchPage = useCallback(async (q: string, off: number, append: boolean) => { if (append) setLoadingMore(true); else setSearching(true); try { const page = q.trim() ? await searchRadioBrowser(q.trim(), off) : await getTopRadioStations(off); if (append) setResults(prev => [...prev, ...page]); else setResults(page); setHasMore(page.length >= RADIO_PAGE_SIZE); setOffset(off + page.length); } catch { if (!append) setResults([]); setHasMore(false); } finally { if (append) setLoadingMore(false); else setSearching(false); } }, []); // Load top stations on open useEffect(() => { fetchPage('', 0, false); }, [fetchPage]); // Debounced search; reset pagination on new query useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { setOffset(0); setHasMore(true); fetchPage(query, 0, false); }, query.trim() ? 400 : 0); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [query, fetchPage]); // Callback ref: re-creates the IntersectionObserver whenever hasMore/loadingMore/offset change, // so the closure always captures current state — no stale refs needed. const sentinelRef = useCallback((node: HTMLDivElement | null) => { if (observerRef.current) { observerRef.current.disconnect(); observerRef.current = null; } if (!node) return; const root = scrollContainerRef.current ?? null; observerRef.current = new IntersectionObserver(entries => { const entry = entries[0]; console.log('[RadioDir] Observer fired:', entry.isIntersecting, '| hasMore:', hasMore, '| loading:', loadingMore); if (entry.isIntersecting && hasMore && !loadingMore) { fetchPage(queryRef.current, offset, true); } }, { root, rootMargin: '200px', threshold: 0 }); observerRef.current.observe(node); }, [hasMore, loadingMore, offset, fetchPage]); const handleAdd = async (s: RadioBrowserStation) => { if (addedIds.has(s.stationuuid) || addingId !== null) return; setAddingId(s.stationuuid); try { await createInternetRadioStation(s.name, s.url); if (s.favicon) { const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]); const created = list.find(r => r.streamUrl === s.url); if (created) { try { const [fileBytes, mimeType] = await fetchUrlBytes(s.favicon); await uploadRadioCoverArtBytes(created.id, fileBytes, mimeType); } catch { /* favicon optional */ } } } onAdded(); setAddedIds(prev => new Set(prev).add(s.stationuuid)); showToast(`${t('radio.stationAdded')}: ${s.name}`, 3000); } catch (err) { const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : ''); if (msg.toLowerCase().includes('unique constraint') || msg.toLowerCase().includes('radio.name')) { showToast('Ein Sender mit diesem Namen existiert bereits.', 4000, 'error'); } else { showToast(msg || 'Failed', 3000, 'error'); } } finally { setAddingId(null); } }; return createPortal( // ── 1. Backdrop ──────────────────────────────────────────────
{ if (e.target === e.currentTarget) onClose(); }} > {/* ── 2. Content Box ─────────────────────────────────────── */}
e.stopPropagation()} > {/* ── 3. Header ──────────────────────────────────────────── */}

{t('radio.browseDirectory')}

setQuery(e.target.value)} placeholder={t('radio.directoryPlaceholder')} autoFocus style={{ width: '100%' }} />
{/* ── 4. Body / Results ──────────────────────────────────── */}
{searching ? (
) : results.length === 0 ? (
{t('radio.noResults')}
) : (
{results.map(s => { const isAdded = addedIds.has(s.stationuuid); const isLoading = addingId === s.stationuuid; const isDisabled = isAdded || addingId !== null; return (
handleAdd(s)} > {s.favicon ? ( { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : (
)}
{s.name}
{s.tags && (
{s.tags.split(',').slice(0, 4).map(tag => tag.trim()).filter(Boolean).join(' · ')}
)}
{isLoading ? : isAdded ? : }
); })} {/* Sentinel for IntersectionObserver */}
{loadingMore && (
)}
)}
, document.body ); }