import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Cast, Check, Loader2, Plus, X } from 'lucide-react'; import { createInternetRadioStation, fetchUrlBytes, getInternetRadioStations, getTopRadioStations, searchRadioBrowser, uploadRadioCoverArtBytes, } from '../../api/subsonicRadio'; import { type InternetRadioStation, type RadioBrowserStation, RADIO_PAGE_SIZE, } from '../../api/subsonicTypes'; import { showToast } from '../../utils/ui/toast'; interface RadioDirectoryModalProps { onClose: () => void; onAdded: () => void; } export default 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 ); }