mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(internet-radio): G.86 — extract Toolbar + AlphabetFilterBar + RadioCard + RadioEditModal + RadioDirectoryModal (#653)
* 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/.
This commit is contained in:
committed by
GitHub
parent
bb0fe828bf
commit
c8e130ecea
@@ -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<string>;
|
||||||
|
onSelect: (l: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlphabetFilterBar({ activeLetter, availableLetters, onSelect }: AlphabetFilterBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="alphabet-filter-bar">
|
||||||
|
{ALPHABET_KEYS.map(l => {
|
||||||
|
const available = availableLetters.has(l);
|
||||||
|
const active = activeLetter === l;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
className={`alphabet-filter-btn${active ? ' active' : ''}${!available ? ' empty' : ''}`}
|
||||||
|
onClick={() => { if (available) onSelect(l); }}
|
||||||
|
tabIndex={available ? 0 : -1}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
className={[
|
||||||
|
'album-card radio-card',
|
||||||
|
isActive ? 'radio-card-active' : '',
|
||||||
|
dropIndicator === 'before' ? 'radio-card-drop-before' : '',
|
||||||
|
dropIndicator === 'after' ? 'radio-card-drop-after' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
style={{ cursor: isManual ? 'grab' : 'default', opacity: isBeingDragged ? 0.4 : 1 }}
|
||||||
|
{...(isManual ? dragHandlers : {})}
|
||||||
|
onMouseMove={e => {
|
||||||
|
if (!isDragging || !isManual) return;
|
||||||
|
const side = getSide(e);
|
||||||
|
lastSideRef.current = side;
|
||||||
|
onDragEnter(side);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => { onDragLeave(); onCardMouseLeave(); }}
|
||||||
|
>
|
||||||
|
{/* Cover */}
|
||||||
|
<div className="album-card-cover">
|
||||||
|
{s.coverArt ? (
|
||||||
|
<CachedImage
|
||||||
|
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
|
||||||
|
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
|
||||||
|
alt={s.name}
|
||||||
|
className="album-card-cover-img"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="album-card-cover-placeholder playlist-card-icon">
|
||||||
|
<Cast size={48} strokeWidth={1.2} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && isPlaying && (
|
||||||
|
<div className="radio-live-overlay">
|
||||||
|
<span className="radio-live-badge">{t('radio.live')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="album-card-play-overlay">
|
||||||
|
<button className="album-card-details-btn" onClick={onPlay}>
|
||||||
|
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`playlist-card-delete ${deleteConfirmId === s.id ? 'playlist-card-delete--confirm' : ''}`}
|
||||||
|
onClick={onDelete}
|
||||||
|
data-tooltip={deleteConfirmId === s.id ? t('radio.confirmDelete') : t('radio.deleteStation')}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
>
|
||||||
|
{deleteConfirmId === s.id ? <Trash2 size={12} /> : <X size={12} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="album-card-info">
|
||||||
|
<div className="album-card-title">{s.name}</div>
|
||||||
|
<div className="album-card-artist" style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
|
||||||
|
<button className="radio-card-chip" onClick={onEdit}>
|
||||||
|
{t('radio.editStation')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`player-btn player-btn-sm radio-favorite-btn${isFavorite ? ' active' : ''}`}
|
||||||
|
onClick={e => { e.stopPropagation(); onFavoriteToggle(); }}
|
||||||
|
data-tooltip={t(isFavorite ? 'radio.unfavorite' : 'radio.favorite')}
|
||||||
|
>
|
||||||
|
<Heart size={11} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
{s.homepageUrl && (
|
||||||
|
<button
|
||||||
|
className="player-btn player-btn-sm"
|
||||||
|
style={{ opacity: 0.6 }}
|
||||||
|
onClick={() => open(s.homepageUrl!)}
|
||||||
|
data-tooltip={t('radio.openHomepage')}
|
||||||
|
>
|
||||||
|
<Globe size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<RadioBrowserStation[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [addingId, setAddingId] = useState<string | null>(null);
|
||||||
|
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(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 ──────────────────────────────────────────────
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(17,17,27,0.85)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
{/* ── 2. Content Box ─────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '80vw',
|
||||||
|
maxWidth: 800,
|
||||||
|
height: '80vh',
|
||||||
|
background: 'var(--ctp-surface0)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* ── 3. Header ──────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: 20,
|
||||||
|
background: 'var(--ctp-surface0)',
|
||||||
|
zIndex: 10,
|
||||||
|
position: 'relative',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ position: 'absolute', top: 16, right: 16, color: 'var(--text-muted)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 14, color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
|
||||||
|
{t('radio.browseDirectory')}
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
placeholder={t('radio.directoryPlaceholder')}
|
||||||
|
autoFocus
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 4. Body / Results ──────────────────────────────────── */}
|
||||||
|
<div ref={scrollContainerRef} style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 20px 20px' }}>
|
||||||
|
{searching ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('radio.noResults')}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingTop: 8 }}>
|
||||||
|
{results.map(s => {
|
||||||
|
const isAdded = addedIds.has(s.stationuuid);
|
||||||
|
const isLoading = addingId === s.stationuuid;
|
||||||
|
const isDisabled = isAdded || addingId !== null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.stationuuid}
|
||||||
|
className={`radio-browser-result${isAdded ? ' added' : ''}${isDisabled ? '' : ' clickable'}`}
|
||||||
|
onClick={() => handleAdd(s)}
|
||||||
|
>
|
||||||
|
{s.favicon ? (
|
||||||
|
<img
|
||||||
|
src={s.favicon}
|
||||||
|
alt=""
|
||||||
|
className="radio-browser-favicon"
|
||||||
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="radio-browser-favicon radio-browser-favicon--placeholder">
|
||||||
|
<Cast size={16} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="radio-browser-info">
|
||||||
|
<div className="radio-browser-name">{s.name}</div>
|
||||||
|
{s.tags && (
|
||||||
|
<div className="radio-browser-tags">
|
||||||
|
{s.tags.split(',').slice(0, 4).map(tag => tag.trim()).filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="radio-browser-action" aria-hidden>
|
||||||
|
{isLoading
|
||||||
|
? <Loader2 size={14} className="spin-slow" style={{ color: 'var(--accent)' }} />
|
||||||
|
: isAdded
|
||||||
|
? <Check size={14} style={{ color: 'var(--accent)' }} />
|
||||||
|
: <Plus size={14} style={{ color: 'var(--text-muted)' }} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Sentinel for IntersectionObserver */}
|
||||||
|
<div ref={sentinelRef} style={{ height: 20, width: '100%', flexShrink: 0 }} />
|
||||||
|
{loadingMore && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0' }}>
|
||||||
|
<div className="spinner" style={{ width: 20, height: 20 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<File | null>(null);
|
||||||
|
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
||||||
|
const [coverRemoved, setCoverRemoved] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const hasExistingCover = !coverRemoved && (coverPreview || station?.coverArt);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="modal-overlay" style={{ alignItems: 'center', paddingTop: 0, overflowY: 'auto' }} onClick={handleOverlayClick}>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
style={{ maxWidth: 440, width: '90%', maxHeight: 'none', overflow: 'visible' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 className="modal-title" style={{ fontSize: 20 }}>
|
||||||
|
{station ? t('radio.editStation') : t('radio.addStation')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Cover + fields side by side */}
|
||||||
|
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', marginBottom: 20 }}>
|
||||||
|
{/* Cover */}
|
||||||
|
<div
|
||||||
|
className="playlist-edit-cover-wrap"
|
||||||
|
style={{ width: 140, height: 140 }}
|
||||||
|
onClick={() => coverInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{coverPreview ? (
|
||||||
|
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
) : !coverRemoved && station?.coverArt ? (
|
||||||
|
<CachedImage
|
||||||
|
src={buildCoverArtUrl(`ra-${station.id}`, 256)}
|
||||||
|
cacheKey={coverArtCacheKey(`ra-${station.id}`, 256)}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="album-card-cover-placeholder playlist-card-icon" style={{ width: '100%', height: '100%', borderRadius: 0 }}>
|
||||||
|
<Cast size={36} strokeWidth={1.2} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="playlist-edit-cover-overlay">
|
||||||
|
<div className="playlist-edit-cover-menu">
|
||||||
|
<button
|
||||||
|
className="playlist-edit-cover-menu-item"
|
||||||
|
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
|
||||||
|
>
|
||||||
|
<Camera size={13} />
|
||||||
|
{t('radio.changeCoverLabel')}
|
||||||
|
</button>
|
||||||
|
{hasExistingCover && (
|
||||||
|
<button
|
||||||
|
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
|
||||||
|
onClick={handleRemoveCover}
|
||||||
|
>
|
||||||
|
{t('radio.removeCover')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ fontSize: 15, fontWeight: 600 }}
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder={t('radio.stationName')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={streamUrl}
|
||||||
|
onChange={e => setStreamUrl(e.target.value)}
|
||||||
|
placeholder={t('radio.streamUrl')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={homepageUrl}
|
||||||
|
onChange={e => setHomepageUrl(e.target.value)}
|
||||||
|
placeholder={t('radio.homepageUrl')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="playlist-edit-footer">
|
||||||
|
<div />
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !name.trim() || !streamUrl.trim()}
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
|
||||||
|
{t('radio.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="radio-toolbar">
|
||||||
|
<div className="radio-toolbar-chips">
|
||||||
|
{(['all', 'favorites'] as const).map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
className={`radio-filter-chip${activeFilter === f ? ' active' : ''}`}
|
||||||
|
onClick={() => onFilterChange(f)}
|
||||||
|
>
|
||||||
|
{f === 'all' ? t('radio.filterAll') : t('radio.filterFavorites')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<CustomSelect
|
||||||
|
value={sortBy}
|
||||||
|
options={sortOptions}
|
||||||
|
onChange={v => onSortChange(v as RadioSortBy)}
|
||||||
|
style={{ width: 'max-content', minWidth: 130, maxWidth: 220, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+5
-600
@@ -12,6 +12,11 @@ import CustomSelect from '../components/CustomSelect';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { open } from '@tauri-apps/plugin-shell';
|
import { open } from '@tauri-apps/plugin-shell';
|
||||||
import { showToast } from '../utils/toast';
|
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() {
|
export default function InternetRadio() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -291,605 +296,5 @@ export default function InternetRadio() {
|
|||||||
|
|
||||||
// ── Toolbar ───────────────────────────────────────────────────────────────────
|
// ── 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 (
|
|
||||||
<div className="radio-toolbar">
|
|
||||||
<div className="radio-toolbar-chips">
|
|
||||||
{(['all', 'favorites'] as const).map(f => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
className={`radio-filter-chip${activeFilter === f ? ' active' : ''}`}
|
|
||||||
onClick={() => onFilterChange(f)}
|
|
||||||
>
|
|
||||||
{f === 'all' ? t('radio.filterAll') : t('radio.filterFavorites')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<CustomSelect
|
|
||||||
value={sortBy}
|
|
||||||
options={sortOptions}
|
|
||||||
onChange={v => onSortChange(v as RadioToolbarProps['sortBy'])}
|
|
||||||
style={{ width: 'max-content', minWidth: 130, maxWidth: 220, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Alphabet Filter Bar ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ALPHABET_KEYS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))];
|
|
||||||
|
|
||||||
interface AlphabetFilterBarProps {
|
|
||||||
activeLetter: string | null;
|
|
||||||
availableLetters: Set<string>;
|
|
||||||
onSelect: (l: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlphabetFilterBar({ activeLetter, availableLetters, onSelect }: AlphabetFilterBarProps) {
|
|
||||||
return (
|
|
||||||
<div className="alphabet-filter-bar">
|
|
||||||
{ALPHABET_KEYS.map(l => {
|
|
||||||
const available = availableLetters.has(l);
|
|
||||||
const active = activeLetter === l;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={l}
|
|
||||||
className={`alphabet-filter-btn${active ? ' active' : ''}${!available ? ' empty' : ''}`}
|
|
||||||
onClick={() => { if (available) onSelect(l); }}
|
|
||||||
tabIndex={available ? 0 : -1}
|
|
||||||
>
|
|
||||||
{l}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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<HTMLDivElement>(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 (
|
|
||||||
<div
|
|
||||||
ref={cardRef}
|
|
||||||
className={[
|
|
||||||
'album-card radio-card',
|
|
||||||
isActive ? 'radio-card-active' : '',
|
|
||||||
dropIndicator === 'before' ? 'radio-card-drop-before' : '',
|
|
||||||
dropIndicator === 'after' ? 'radio-card-drop-after' : '',
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
style={{ cursor: isManual ? 'grab' : 'default', opacity: isBeingDragged ? 0.4 : 1 }}
|
|
||||||
{...(isManual ? dragHandlers : {})}
|
|
||||||
onMouseMove={e => {
|
|
||||||
if (!isDragging || !isManual) return;
|
|
||||||
const side = getSide(e);
|
|
||||||
lastSideRef.current = side;
|
|
||||||
onDragEnter(side);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => { onDragLeave(); onCardMouseLeave(); }}
|
|
||||||
>
|
|
||||||
{/* Cover */}
|
|
||||||
<div className="album-card-cover">
|
|
||||||
{s.coverArt ? (
|
|
||||||
<CachedImage
|
|
||||||
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
|
|
||||||
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
|
|
||||||
alt={s.name}
|
|
||||||
className="album-card-cover-img"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="album-card-cover-placeholder playlist-card-icon">
|
|
||||||
<Cast size={48} strokeWidth={1.2} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isActive && isPlaying && (
|
|
||||||
<div className="radio-live-overlay">
|
|
||||||
<span className="radio-live-badge">{t('radio.live')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="album-card-play-overlay">
|
|
||||||
<button className="album-card-details-btn" onClick={onPlay}>
|
|
||||||
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`playlist-card-delete ${deleteConfirmId === s.id ? 'playlist-card-delete--confirm' : ''}`}
|
|
||||||
onClick={onDelete}
|
|
||||||
data-tooltip={deleteConfirmId === s.id ? t('radio.confirmDelete') : t('radio.deleteStation')}
|
|
||||||
data-tooltip-pos="bottom"
|
|
||||||
>
|
|
||||||
{deleteConfirmId === s.id ? <Trash2 size={12} /> : <X size={12} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="album-card-info">
|
|
||||||
<div className="album-card-title">{s.name}</div>
|
|
||||||
<div className="album-card-artist" style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
|
|
||||||
<button className="radio-card-chip" onClick={onEdit}>
|
|
||||||
{t('radio.editStation')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`player-btn player-btn-sm radio-favorite-btn${isFavorite ? ' active' : ''}`}
|
|
||||||
onClick={e => { e.stopPropagation(); onFavoriteToggle(); }}
|
|
||||||
data-tooltip={t(isFavorite ? 'radio.unfavorite' : 'radio.favorite')}
|
|
||||||
>
|
|
||||||
<Heart size={11} fill={isFavorite ? 'currentColor' : 'none'} />
|
|
||||||
</button>
|
|
||||||
{s.homepageUrl && (
|
|
||||||
<button
|
|
||||||
className="player-btn player-btn-sm"
|
|
||||||
style={{ opacity: 0.6 }}
|
|
||||||
onClick={() => open(s.homepageUrl!)}
|
|
||||||
data-tooltip={t('radio.openHomepage')}
|
|
||||||
>
|
|
||||||
<Globe size={11} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<File | null>(null);
|
|
||||||
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
|
||||||
const [coverRemoved, setCoverRemoved] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const hasExistingCover = !coverRemoved && (coverPreview || station?.coverArt);
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
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 (
|
|
||||||
<div className="modal-overlay" style={{ alignItems: 'center', paddingTop: 0, overflowY: 'auto' }} onClick={handleOverlayClick}>
|
|
||||||
<div
|
|
||||||
className="modal-content"
|
|
||||||
style={{ maxWidth: 440, width: '90%', maxHeight: 'none', overflow: 'visible' }}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h2 className="modal-title" style={{ fontSize: 20 }}>
|
|
||||||
{station ? t('radio.editStation') : t('radio.addStation')}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Cover + fields side by side */}
|
|
||||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', marginBottom: 20 }}>
|
|
||||||
{/* Cover */}
|
|
||||||
<div
|
|
||||||
className="playlist-edit-cover-wrap"
|
|
||||||
style={{ width: 140, height: 140 }}
|
|
||||||
onClick={() => coverInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
{coverPreview ? (
|
|
||||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
|
||||||
) : !coverRemoved && station?.coverArt ? (
|
|
||||||
<CachedImage
|
|
||||||
src={buildCoverArtUrl(`ra-${station.id}`, 256)}
|
|
||||||
cacheKey={coverArtCacheKey(`ra-${station.id}`, 256)}
|
|
||||||
alt=""
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="album-card-cover-placeholder playlist-card-icon" style={{ width: '100%', height: '100%', borderRadius: 0 }}>
|
|
||||||
<Cast size={36} strokeWidth={1.2} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="playlist-edit-cover-overlay">
|
|
||||||
<div className="playlist-edit-cover-menu">
|
|
||||||
<button
|
|
||||||
className="playlist-edit-cover-menu-item"
|
|
||||||
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
|
|
||||||
>
|
|
||||||
<Camera size={13} />
|
|
||||||
{t('radio.changeCoverLabel')}
|
|
||||||
</button>
|
|
||||||
{hasExistingCover && (
|
|
||||||
<button
|
|
||||||
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
|
|
||||||
onClick={handleRemoveCover}
|
|
||||||
>
|
|
||||||
{t('radio.removeCover')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fields */}
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
style={{ fontSize: 15, fontWeight: 600 }}
|
|
||||||
value={name}
|
|
||||||
onChange={e => setName(e.target.value)}
|
|
||||||
placeholder={t('radio.stationName')}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={streamUrl}
|
|
||||||
onChange={e => setStreamUrl(e.target.value)}
|
|
||||||
placeholder={t('radio.streamUrl')}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={homepageUrl}
|
|
||||||
onChange={e => setHomepageUrl(e.target.value)}
|
|
||||||
placeholder={t('radio.homepageUrl')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="playlist-edit-footer">
|
|
||||||
<div />
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !name.trim() || !streamUrl.trim()}
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
|
|
||||||
{t('radio.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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<RadioBrowserStation[]>([]);
|
|
||||||
const [searching, setSearching] = useState(false);
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
const [addingId, setAddingId] = useState<string | null>(null);
|
|
||||||
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(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 ──────────────────────────────────────────────
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 9999,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'rgba(17,17,27,0.85)',
|
|
||||||
backdropFilter: 'blur(8px)',
|
|
||||||
}}
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
|
||||||
{/* ── 2. Content Box ─────────────────────────────────────── */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '80vw',
|
|
||||||
maxWidth: 800,
|
|
||||||
height: '80vh',
|
|
||||||
background: 'var(--ctp-surface0)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 12,
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* ── 3. Header ──────────────────────────────────────────── */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
padding: 20,
|
|
||||||
background: 'var(--ctp-surface0)',
|
|
||||||
zIndex: 10,
|
|
||||||
position: 'relative',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ position: 'absolute', top: 16, right: 16, color: 'var(--text-muted)' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 14, color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
|
|
||||||
{t('radio.browseDirectory')}
|
|
||||||
</h2>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={query}
|
|
||||||
onChange={e => setQuery(e.target.value)}
|
|
||||||
placeholder={t('radio.directoryPlaceholder')}
|
|
||||||
autoFocus
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 4. Body / Results ──────────────────────────────────── */}
|
|
||||||
<div ref={scrollContainerRef} style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 20px 20px' }}>
|
|
||||||
{searching ? (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
|
|
||||||
<div className="spinner" />
|
|
||||||
</div>
|
|
||||||
) : results.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('radio.noResults')}</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingTop: 8 }}>
|
|
||||||
{results.map(s => {
|
|
||||||
const isAdded = addedIds.has(s.stationuuid);
|
|
||||||
const isLoading = addingId === s.stationuuid;
|
|
||||||
const isDisabled = isAdded || addingId !== null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={s.stationuuid}
|
|
||||||
className={`radio-browser-result${isAdded ? ' added' : ''}${isDisabled ? '' : ' clickable'}`}
|
|
||||||
onClick={() => handleAdd(s)}
|
|
||||||
>
|
|
||||||
{s.favicon ? (
|
|
||||||
<img
|
|
||||||
src={s.favicon}
|
|
||||||
alt=""
|
|
||||||
className="radio-browser-favicon"
|
|
||||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="radio-browser-favicon radio-browser-favicon--placeholder">
|
|
||||||
<Cast size={16} strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="radio-browser-info">
|
|
||||||
<div className="radio-browser-name">{s.name}</div>
|
|
||||||
{s.tags && (
|
|
||||||
<div className="radio-browser-tags">
|
|
||||||
{s.tags.split(',').slice(0, 4).map(tag => tag.trim()).filter(Boolean).join(' · ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="radio-browser-action" aria-hidden>
|
|
||||||
{isLoading
|
|
||||||
? <Loader2 size={14} className="spin-slow" style={{ color: 'var(--accent)' }} />
|
|
||||||
: isAdded
|
|
||||||
? <Check size={14} style={{ color: 'var(--accent)' }} />
|
|
||||||
: <Plus size={14} style={{ color: 'var(--text-muted)' }} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* Sentinel for IntersectionObserver */}
|
|
||||||
<div ref={sentinelRef} style={{ height: 20, width: '100%', flexShrink: 0 }} />
|
|
||||||
{loadingMore && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0' }}>
|
|
||||||
<div className="spinner" style={{ width: 20, height: 20 }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user