From c8e130ecea3cd09fdef2bb8a69307234e76790a0 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 17:47:00 +0200 Subject: [PATCH] =?UTF-8?q?refactor(internet-radio):=20G.86=20=E2=80=94=20?= =?UTF-8?q?extract=20Toolbar=20+=20AlphabetFilterBar=20+=20RadioCard=20+?= =?UTF-8?q?=20RadioEditModal=20+=20RadioDirectoryModal=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(internet-radio): G.86.1 — extract RadioToolbar + AlphabetFilterBar First cut on InternetRadio.tsx: pulled the two header bars into their own files under src/components/internetRadio/. RadioToolbar exports the RadioSortBy type alias so the page state and the toolbar share the same union. AlphabetFilterBar owns the A-Z + # key list internally. Pure code move. * refactor(internet-radio): G.86.2 — extract RadioCard Pulled the single radio-station card component (cover, live overlay, play/delete buttons, name + edit/favourite/homepage chip row) into its own file. It owns its drag source + the psy-drop listener that fires onDropOnto with the cursor-side (before/after). Pure code move. * refactor(internet-radio): G.86.3 — extract RadioEditModal Pulled the create/edit-station modal (cover preview + change/remove, name + stream URL + homepage URL fields, save spinner) into its own file. station=null means "create new". Pure code move. * refactor(internet-radio): G.86.4 — extract RadioDirectoryModal Pulled the radio-browser directory modal (top-stations preload, debounced search, IntersectionObserver-driven pagination, favicon + add-station flow with cover upload from favicon URL) into its own file. Pure code move. InternetRadio.tsx is now 299 LOC — every subcomponent lives in src/components/internetRadio/. --- .../internetRadio/AlphabetFilterBar.tsx | 30 + src/components/internetRadio/RadioCard.tsx | 150 +++++ .../internetRadio/RadioDirectoryModal.tsx | 241 +++++++ .../internetRadio/RadioEditModal.tsx | 163 +++++ src/components/internetRadio/RadioToolbar.tsx | 43 ++ src/pages/InternetRadio.tsx | 605 +----------------- 6 files changed, 632 insertions(+), 600 deletions(-) create mode 100644 src/components/internetRadio/AlphabetFilterBar.tsx create mode 100644 src/components/internetRadio/RadioCard.tsx create mode 100644 src/components/internetRadio/RadioDirectoryModal.tsx create mode 100644 src/components/internetRadio/RadioEditModal.tsx create mode 100644 src/components/internetRadio/RadioToolbar.tsx diff --git a/src/components/internetRadio/AlphabetFilterBar.tsx b/src/components/internetRadio/AlphabetFilterBar.tsx new file mode 100644 index 00000000..4b068bfc --- /dev/null +++ b/src/components/internetRadio/AlphabetFilterBar.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +const ALPHABET_KEYS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))]; + +interface AlphabetFilterBarProps { + activeLetter: string | null; + availableLetters: Set; + onSelect: (l: string) => void; +} + +export default function AlphabetFilterBar({ activeLetter, availableLetters, onSelect }: AlphabetFilterBarProps) { + return ( +
+ {ALPHABET_KEYS.map(l => { + const available = availableLetters.has(l); + const active = activeLetter === l; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/internetRadio/RadioCard.tsx b/src/components/internetRadio/RadioCard.tsx new file mode 100644 index 00000000..087af928 --- /dev/null +++ b/src/components/internetRadio/RadioCard.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Cast, Globe, Heart, Trash2, X } from 'lucide-react'; +import { open } from '@tauri-apps/plugin-shell'; +import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl'; +import type { InternetRadioStation } from '../../api/subsonicTypes'; +import { useDragDrop, useDragSource } from '../../contexts/DragDropContext'; +import CachedImage from '../CachedImage'; + +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; +} + +export default 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 && ( + + )} +
+
+
+ ); +} diff --git a/src/components/internetRadio/RadioDirectoryModal.tsx b/src/components/internetRadio/RadioDirectoryModal.tsx new file mode 100644 index 00000000..a558b58b --- /dev/null +++ b/src/components/internetRadio/RadioDirectoryModal.tsx @@ -0,0 +1,241 @@ +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/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 + ); +} diff --git a/src/components/internetRadio/RadioEditModal.tsx b/src/components/internetRadio/RadioEditModal.tsx new file mode 100644 index 00000000..564720ce --- /dev/null +++ b/src/components/internetRadio/RadioEditModal.tsx @@ -0,0 +1,163 @@ +import React, { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Camera, Cast, Loader2, X } from 'lucide-react'; +import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl'; +import type { InternetRadioStation } from '../../api/subsonicTypes'; +import CachedImage from '../CachedImage'; + +interface RadioEditModalProps { + station: InternetRadioStation | null; // null = create new + onClose: () => void; + onSave: (opts: { + name: string; + streamUrl: string; + homepageUrl: string; + coverFile: File | null; + coverRemoved: boolean; + }) => Promise; +} + +export default 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')} + /> +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/components/internetRadio/RadioToolbar.tsx b/src/components/internetRadio/RadioToolbar.tsx new file mode 100644 index 00000000..d9ce3333 --- /dev/null +++ b/src/components/internetRadio/RadioToolbar.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import CustomSelect from '../CustomSelect'; + +export type RadioSortBy = 'manual' | 'az' | 'za' | 'newest'; + +interface RadioToolbarProps { + sortBy: RadioSortBy; + activeFilter: string; + onSortChange: (s: RadioSortBy) => void; + onFilterChange: (f: string) => void; +} + +export default 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 RadioSortBy)} + style={{ width: 'max-content', minWidth: 130, maxWidth: 220, flexShrink: 0 }} + /> +
+ ); +} diff --git a/src/pages/InternetRadio.tsx b/src/pages/InternetRadio.tsx index bfaafd5f..54d7fc07 100644 --- a/src/pages/InternetRadio.tsx +++ b/src/pages/InternetRadio.tsx @@ -12,6 +12,11 @@ import CustomSelect from '../components/CustomSelect'; import { useTranslation } from 'react-i18next'; import { open } from '@tauri-apps/plugin-shell'; import { showToast } from '../utils/toast'; +import RadioToolbar, { type RadioSortBy } from '../components/internetRadio/RadioToolbar'; +import AlphabetFilterBar from '../components/internetRadio/AlphabetFilterBar'; +import RadioCard from '../components/internetRadio/RadioCard'; +import RadioEditModal from '../components/internetRadio/RadioEditModal'; +import RadioDirectoryModal from '../components/internetRadio/RadioDirectoryModal'; export default function InternetRadio() { const { t } = useTranslation(); @@ -291,605 +296,5 @@ export default function InternetRadio() { // ── 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 - ); -}