refactor(radio): co-locate internet radio feature into features/radio

This commit is contained in:
Psychotoxical
2026-06-29 23:04:34 +02:00
parent dbffe59e58
commit 896fe3f407
18 changed files with 61 additions and 47 deletions
+103
View File
@@ -0,0 +1,103 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
import { api } from '@/api/subsonicClient';
import type { InternetRadioStation, RadioBrowserStation } from '@/api/subsonicTypes';
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
try {
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
'getInternetRadioStations.view'
);
return data.internetRadioStations?.internetRadioStation ?? [];
} catch {
return [];
}
}
export async function createInternetRadioStation(
name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('createInternetRadioStation.view', params);
}
export async function updateInternetRadioStation(
id: string, name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { id, name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('updateInternetRadioStation.view', params);
}
export async function deleteInternetRadioStation(id: string): Promise<void> {
await api('deleteInternetRadioStation.view', { id });
}
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deleteRadioCoverArt(id: string): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('delete_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
});
}
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType,
});
}
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
return raw.map(s => ({
stationuuid: s.stationuuid ?? '',
name: s.name ?? '',
url: s.url ?? '',
favicon: s.favicon ?? '',
tags: s.tags ?? '',
}));
}
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
return parseRadioBrowserStations(raw);
}
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
return parseRadioBrowserStations(raw);
}
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
return invoke<[number[], string]>('fetch_url_bytes', { url });
}
@@ -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>
);
}
+166
View File
@@ -0,0 +1,166 @@
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Cast, Globe, Heart, Square, Trash2, X } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import type { InternetRadioStation } from '@/api/subsonicTypes';
import { useDragDrop, useDragSource } from '@/contexts/DragDropContext';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRef } from '@/cover/ref';
import { coverArtIdFromRadio } from '@/cover/ids';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
interface RadioCardProps {
s: InternetRadioStation;
isActive: boolean;
isPlaying: boolean;
deleteConfirmId: string | null;
isFavorite: boolean;
isManual: boolean;
/** Navidrome ≥ 0.62 only lets admins manage stations — hides edit/delete. */
canManage: 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, canManage, 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 ? (
<CoverArtImage
coverRef={albumCoverRef(coverArtIdFromRadio(s.id), coverArtIdFromRadio(s.id))}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
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}
data-tooltip={isActive && isPlaying ? t('radio.stopStation') : t('radio.playStation')}
data-tooltip-pos="bottom"
>
{isActive && isPlaying ? <Square size={13} fill="currentColor" /> : <Cast size={14} />}
</button>
</div>
{canManage && (
<div className="playlist-card-actions">
<button
className={`playlist-card-action playlist-card-action--delete ${deleteConfirmId === s.id ? 'playlist-card-action--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>
)}
</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' }}>
{canManage && (
<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,243 @@
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 '@/features/radio/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<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(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
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(--bg-card)',
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(--bg-card)',
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,169 @@
import React, { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Camera, Cast, Loader2, X } from 'lucide-react';
import type { InternetRadioStation } from '@/api/subsonicTypes';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRef } from '@/cover/ref';
import { coverArtIdFromRadio } from '@/cover/ids';
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();
};
/* Portal to document.body: nested .content-body uses contain:paint — in-tree
* modals are clipped when the station list is empty (short layout). Same pattern as RadioDirectoryModal. */
return createPortal(
<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 ? (
<CoverArtImage
coverRef={albumCoverRef(coverArtIdFromRadio(station.id), coverArtIdFromRadio(station.id))}
displayCssPx={140}
surface="sparse"
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>,
document.body
);
}
@@ -0,0 +1,43 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import CustomSelect from '@/ui/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>
);
}
@@ -0,0 +1,218 @@
import type { InternetRadioStation } from '@/api/subsonicTypes';
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import {
guessAzuraCastApiUrl,
normaliseAzuraCastHomepageUrl,
fetchAzuraCastNowPlaying,
type AzuraCastNowPlaying,
type AzuraCastSong,
} from '@/api/azuracast';
// ─── Public types ─────────────────────────────────────────────────────────────
export type RadioMetadataSource = 'azuracast' | 'icy' | 'none';
export interface RadioHistoryItem {
song: AzuraCastSong;
playedAt?: number; // unix timestamp
}
export interface RadioMetadata {
/** Metadata source that is currently active. */
source: RadioMetadataSource;
/** Station name (from ICY icy-name or AzuraCast station.name). */
stationName?: string;
/** Current track title (combined or individual fields). */
currentTitle?: string;
currentArtist?: string;
currentAlbum?: string;
currentArt?: string;
/** AzuraCast-only: seconds elapsed in current track. */
elapsed?: number;
/** AzuraCast-only: total duration of current track in seconds. */
duration?: number;
/** AzuraCast-only: number of current listeners. */
listeners?: number;
/** AzuraCast-only: last N played tracks. */
history: RadioHistoryItem[];
/** AzuraCast-only: next track queued. */
nextSong?: AzuraCastSong;
}
// ─── ICY metadata interface (matches Rust IcyMetadata struct) ─────────────────
interface IcyMetadataResult {
stream_title?: string;
icy_name?: string;
icy_genre?: string;
icy_url?: string;
icy_description?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseIcyStreamTitle(streamTitle: string): { artist?: string; title: string } {
const sep = streamTitle.indexOf(' - ');
if (sep !== -1) {
return { artist: streamTitle.slice(0, sep).trim(), title: streamTitle.slice(sep + 3).trim() };
}
return { title: streamTitle };
}
function nowPlayingToMetadata(np: AzuraCastNowPlaying): RadioMetadata {
const nowPlaying = np.now_playing;
const song = nowPlaying?.song;
return {
source: 'azuracast',
stationName: np.station?.name,
currentTitle: song?.title,
currentArtist: song?.artist,
currentAlbum: song?.album,
currentArt: song?.art,
elapsed: nowPlaying?.elapsed,
duration: nowPlaying?.duration,
listeners: np.listeners?.current,
history: (np.song_history ?? []).slice(0, 5).map(h => ({
song: h.song,
playedAt: h.played_at,
})),
nextSong: np.playing_next?.song ?? undefined,
};
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
const AZURACAST_POLL_MS = 15_000;
const ICY_POLL_MS = 30_000;
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
const perfFlags = usePerfProbeFlags();
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
// Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing.
const elapsedRef = useRef<number | null>(null);
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stationRef = useRef<InternetRadioStation | null>(null);
// Store resolved AzuraCast API URL for the current station (or null).
const azuraCastUrlRef = useRef<string | null>(null);
// Stop the elapsed ticker.
function stopElapsedTick() {
if (elapsedIntervalRef.current) {
clearInterval(elapsedIntervalRef.current);
elapsedIntervalRef.current = null;
}
elapsedRef.current = null;
}
// Start a 1-second elapsed ticker that advances the stored elapsed value and
// updates the metadata state so the progress bar moves smoothly between polls.
function startElapsedTick(initial: number) {
stopElapsedTick();
elapsedRef.current = initial;
elapsedIntervalRef.current = setInterval(() => {
if (elapsedRef.current === null) return;
elapsedRef.current += 1;
setMetadata(prev =>
prev.source === 'azuracast'
? { ...prev, elapsed: elapsedRef.current! }
: prev
);
}, 1000);
}
useEffect(() => {
if (perfFlags.disableBackgroundPolling) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
return;
}
if (!station) {
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
return;
}
stationRef.current = station;
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
let cancelled = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
// Determine which AzuraCast API URL to try, in priority order:
// 1. Homepage URL if it matches the /api/nowplaying[/shortcode] pattern
// 2. Guessed URL from stream URL path (/listen/<shortcode>/…)
const candidateApiUrl =
(station.homepageUrl ? normaliseAzuraCastHomepageUrl(station.homepageUrl) : null) ??
guessAzuraCastApiUrl(station.streamUrl);
async function pollAzuraCast(apiUrl: string) {
if (cancelled) return;
const np = await fetchAzuraCastNowPlaying(apiUrl);
if (cancelled) return;
if (np) {
const m = nowPlayingToMetadata(np);
setMetadata(m);
startElapsedTick(m.elapsed ?? 0);
pollTimer = setTimeout(() => pollAzuraCast(apiUrl), AZURACAST_POLL_MS);
} else {
// AzuraCast check failed — fall back to ICY
azuraCastUrlRef.current = null;
pollIcy();
}
}
async function pollIcy() {
if (cancelled) return;
const currentStation = stationRef.current;
if (!currentStation) return;
try {
const result: IcyMetadataResult = await invoke('fetch_icy_metadata', { url: currentStation.streamUrl });
if (cancelled) return;
if (result.stream_title || result.icy_name) {
const parsed = result.stream_title ? parseIcyStreamTitle(result.stream_title) : null;
setMetadata({
source: 'icy',
stationName: result.icy_name,
currentTitle: parsed?.title,
currentArtist: parsed?.artist,
history: [],
});
}
} catch {
// ICY metadata not available — leave empty metadata
}
if (!cancelled) {
pollTimer = setTimeout(pollIcy, ICY_POLL_MS);
}
}
// Kick off detection and polling.
if (candidateApiUrl) {
// Try AzuraCast first; fall back to ICY inside pollAzuraCast if it fails.
azuraCastUrlRef.current = candidateApiUrl;
pollAzuraCast(candidateApiUrl);
} else {
pollIcy();
}
return () => {
cancelled = true;
if (pollTimer) clearTimeout(pollTimer);
stopElapsedTick();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [station?.id, station?.streamUrl, station?.homepageUrl, perfFlags.disableBackgroundPolling]);
return metadata;
}
@@ -0,0 +1,81 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import { useRadioMprisSync } from '@/features/radio/hooks/useRadioMprisSync';
import type { RadioMetadata } from '@/features/radio/hooks/useRadioMetadata';
import type { InternetRadioStation } from '@/api/subsonicTypes';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(async () => undefined) }));
const invokeMock = vi.mocked(invoke);
// jsdom has no MediaSession / MediaMetadata — stub the standard W3C shapes.
class FakeMediaMetadata {
title?: string;
artist?: string;
album?: string;
artwork?: unknown;
constructor(init: Record<string, unknown>) { Object.assign(this, init); }
}
const ms = { metadata: null as FakeMediaMetadata | null, playbackState: 'none' };
beforeEach(() => {
vi.clearAllMocks();
ms.metadata = null;
ms.playbackState = 'none';
(globalThis as unknown as { MediaMetadata: unknown }).MediaMetadata = FakeMediaMetadata;
Object.defineProperty(navigator, 'mediaSession', { value: ms, configurable: true });
});
const STATION = { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/s' } as InternetRadioStation;
const NONE: RadioMetadata = { source: 'none', history: [] };
const meta = (over: Partial<RadioMetadata>): RadioMetadata => ({ source: 'icy', history: [], ...over });
describe('useRadioMprisSync', () => {
it('feeds resolved radio metadata to navigator.mediaSession', async () => {
renderHook(() =>
useRadioMprisSync(meta({ currentTitle: 'Celebrity Skin', currentArtist: 'Hole' }), STATION));
await waitFor(() => {
expect(ms.metadata?.title).toBe('Celebrity Skin');
expect(ms.metadata?.artist).toBe('Hole');
expect(ms.playbackState).toBe('playing');
});
});
it('mirrors the same metadata to the souvlaki controls', async () => {
renderHook(() => useRadioMprisSync(meta({ currentTitle: 'Mind', currentArtist: 'TWO LANES' }), STATION));
await waitFor(() =>
expect(invokeMock).toHaveBeenCalledWith('mpris_set_metadata',
expect.objectContaining({ title: 'Mind', artist: 'TWO LANES' })));
});
it('falls back to the station name as artist when ICY has no artist', async () => {
renderHook(() => useRadioMprisSync(meta({ currentTitle: 'Some Title' }), STATION));
await waitFor(() => expect(ms.metadata?.artist).toBe('Test FM'));
});
it('does not push when the station sends no track metadata (no regression)', () => {
renderHook(() => useRadioMprisSync(NONE, STATION));
expect(ms.metadata).toBeNull();
expect(invokeMock).not.toHaveBeenCalled();
});
it('does not push when no radio station is active', () => {
renderHook(() => useRadioMprisSync(meta({ currentTitle: 'X' }), null));
expect(ms.metadata).toBeNull();
expect(invokeMock).not.toHaveBeenCalled();
});
it('dedupes unchanged re-emits but updates again when the track changes', async () => {
const { rerender } = renderHook(({ m }) => useRadioMprisSync(m, STATION), {
initialProps: { m: meta({ currentTitle: 'Track A', currentArtist: 'A' }) },
});
await waitFor(() => expect(invokeMock).toHaveBeenCalledTimes(1));
rerender({ m: meta({ currentTitle: 'Track A', currentArtist: 'A' }) });
expect(invokeMock).toHaveBeenCalledTimes(1);
rerender({ m: meta({ currentTitle: 'Track B', currentArtist: 'B' }) });
await waitFor(() => {
expect(invokeMock).toHaveBeenCalledTimes(2);
expect(ms.metadata?.title).toBe('Track B');
});
});
});
@@ -0,0 +1,81 @@
import { useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { InternetRadioStation } from '@/api/subsonicTypes';
import type { RadioMetadata } from '@/features/radio/hooks/useRadioMetadata';
/**
* Internet radio → OS media controls (MPRIS / SMTC / Now Playing).
*
* Internet-radio playback runs through the WebView `<audio>` element
* (`radioPlayer.ts`). WebKitGTK auto-registers its **own** MPRIS player for that
* element, and on Linux desktops the OS overlay shows *that* player — not the
* souvlaki one we drive from Rust. With no metadata fed to it, it just shows the
* app name ("Psysonic") and never updates per track (issue #816, verified via
* D-Bus: the visible player is `org.mpris.MediaPlayer2.org.webkit.*`).
*
* The fix is to feed WebKit's player through the standard W3C
* `navigator.mediaSession` API — the same channel browsers use to surface
* `<audio>`/`<video>` metadata to the OS. We also mirror to the souvlaki
* controls (`mpris_set_metadata`) so desktops that surface *that* player instead
* stay correct too. `useRadioMetadata` already resolves per-track title/artist
* for the in-app UI, so we just forward the same values.
*
* Mount exactly once (in the always-present `PlayerBar`). When the station
* sends no track metadata, the station-name push from `mprisSync` is left in
* place — no regression for metadata-less stations.
*/
export function useRadioMprisSync(
radioMeta: RadioMetadata,
currentRadio: InternetRadioStation | null,
): void {
const lastPushedRef = useRef<string | null>(null);
useEffect(() => {
const ms: MediaSession | undefined =
typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!currentRadio) {
lastPushedRef.current = null;
return;
}
// No resolved track yet → keep the station-name push from mprisSync.
if (radioMeta.source === 'none' || !radioMeta.currentTitle) return;
const title = radioMeta.currentTitle;
const artist = radioMeta.currentArtist || currentRadio.name;
const album = radioMeta.currentAlbum || undefined;
const artUrl = radioMeta.currentArt || undefined;
const key = `${currentRadio.id}|${title}|${artist}|${artUrl ?? ''}`;
if (lastPushedRef.current === key) return;
lastPushedRef.current = key;
// Primary path on Linux/WebKit: populate WebKit's own MPRIS player.
if (ms && typeof MediaMetadata !== 'undefined') {
ms.metadata = new MediaMetadata({
title,
artist,
album,
artwork: artUrl ? [{ src: artUrl }] : undefined,
});
ms.playbackState = 'playing';
}
// Mirror to the souvlaki-backed controls for desktops that surface those.
invoke('mpris_set_metadata', {
title,
artist,
album: album ?? null,
coverUrl: artUrl ?? null,
durationSecs: radioMeta.duration ?? null,
}).catch(() => {});
}, [
currentRadio,
radioMeta.source,
radioMeta.currentTitle,
radioMeta.currentArtist,
radioMeta.currentAlbum,
radioMeta.currentArt,
radioMeta.duration,
]);
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Internet radio feature — station browse/edit UI (directory, cards, toolbar),
* the InternetRadio page, and the live ICY/AzuraCast metadata hooks. The page is
* lazy-loaded by the router via its deep path, so it is not re-exported here.
*
* Note: radio *playback* state (`store/radioPlayer`, `store/radioSessionState`)
* and the ICY→MPRIS bridge (`audioListenerSetup/radioMprisMetadata`) stay in the
* playback/audio core — the player core drives them, so they are not part of
* this UI feature.
*/
export { getInternetRadioStations } from './api/subsonicRadio';
export { useRadioMetadata } from './hooks/useRadioMetadata';
export type { RadioMetadata } from './hooks/useRadioMetadata';
export { useRadioMprisSync } from './hooks/useRadioMprisSync';
+317
View File
@@ -0,0 +1,317 @@
import { getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation, uploadRadioCoverArt, deleteRadioCoverArt } from '@/features/radio/api/subsonicRadio';
import { type InternetRadioStation } from '@/api/subsonicTypes';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { Plus, Search } from 'lucide-react';
import { usePlayerStore } from '@/store/playerStore';
import { setRadioVolume } from '@/store/radioPlayer';
import { fadeOut } from '@/utils/playback/fadeOut';
import { invalidateCoverArt } from '@/utils/imageCache';
import { useTranslation } from 'react-i18next';
import { showToast } from '@/utils/ui/toast';
import RadioToolbar from '@/features/radio/components/RadioToolbar';
import AlphabetFilterBar from '@/features/radio/components/AlphabetFilterBar';
import RadioCard from '@/features/radio/components/RadioCard';
import RadioEditModal from '@/features/radio/components/RadioEditModal';
import RadioDirectoryModal from '@/features/radio/components/RadioDirectoryModal';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
import { useNavidromeAdminRole, canManageNavidromeRadio } from '@/hooks/useNavidromeAdminRole';
export default function InternetRadio() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
// Navidrome ≥ 0.62: only admins may create/edit/delete radio stations.
const canManage = canManageNavidromeRadio(useNavidromeAdminRole());
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<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// null = closed, 'new' = create modal, InternetRadioStation = edit modal
const [modalStation, setModalStation] = useState<InternetRadioStation | 'new' | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [sortBy, setSortBy] = useState<'manual' | 'az' | 'za' | 'newest'>('manual');
const [activeFilter, setActiveFilter] = useState('all');
const [activeLetter, setActiveLetter] = useState<string | null>(null);
const [favorites, setFavorites] = useState<Set<string>>(() => {
try { return new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]')); }
catch { return new Set<string>(); }
});
const [manualOrder, setManualOrder] = useState<string[]>([]);
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); });
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
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<string>();
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) {
if (isPlaying) {
const vol = usePlayerStore.getState().volume;
await fadeOut(setRadioVolume, vol, 700);
}
stop();
}
try {
await deleteInternetRadioStation(s.id);
setStations(prev => prev.filter(st => st.id !== s.id));
} catch { /* ignore: best-effort */ }
setDeleteConfirmId(null);
};
const handlePlay = (e: React.MouseEvent, s: InternetRadioStation) => {
e.stopPropagation();
if (currentRadio?.id === s.id && isPlaying) {
stop();
} else {
playRadio(s);
}
};
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
);
}
return (
<div className="content-body animate-fade-in">
{/* ── Header ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('radio.title')}</h1>
{canManage && (
<div className="compact-action-bar" style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => setBrowseOpen(true)} aria-label={t('radio.browseDirectory')} data-tooltip={t('radio.browseDirectory')}>
<Search size={14} /> <span className="compact-btn-label">{t('radio.browseDirectory')}</span>
</button>
<button className="btn btn-primary" onClick={() => setModalStation('new')} aria-label={t('radio.addStation')} data-tooltip={t('radio.addStation')}>
<Plus size={15} /> <span className="compact-btn-label">{t('radio.addStation')}</span>
</button>
</div>
)}
</div>
{/* ── Toolbar + Grid ── */}
{stations.length === 0 ? (
<div className="empty-state">{t('radio.empty')}</div>
) : (
<>
<RadioToolbar
sortBy={sortBy}
activeFilter={activeFilter}
onSortChange={setSortBy}
onFilterChange={f => { setActiveFilter(f); setActiveLetter(null); }}
/>
<AlphabetFilterBar
activeLetter={activeLetter}
availableLetters={availableLetters}
onSelect={l => setActiveLetter(prev => prev === l ? null : l)}
/>
{displayedStations.length === 0 ? (
<div className="empty-state">{t('radio.noFavorites')}</div>
) : (
<VirtualCardGrid
items={displayedStations}
itemKey={(s, _i) => s.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={displayedStations.length}
renderItem={s => (
<RadioCard
s={s}
isActive={currentRadio?.id === s.id}
isPlaying={isPlaying}
deleteConfirmId={deleteConfirmId}
isFavorite={favorites.has(s.id)}
isManual={sortBy === 'manual'}
canManage={canManage}
dropIndicator={dragOver?.id === s.id ? dragOver.side : null}
onPlay={e => 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 ── */}
{canManage && modalStation !== null && (
<RadioEditModal
station={modalStation === 'new' ? null : modalStation}
onClose={() => setModalStation(null)}
onSave={handleSave}
/>
)}
{/* ── Directory Modal ── */}
{canManage && browseOpen && (
<RadioDirectoryModal
onClose={() => setBrowseOpen(false)}
onAdded={reload}
/>
)}
</div>
);
}
// ── Toolbar ───────────────────────────────────────────────────────────────────