diff --git a/src/pages/InternetRadio.tsx b/src/pages/InternetRadio.tsx new file mode 100644 index 00000000..b0a79789 --- /dev/null +++ b/src/pages/InternetRadio.tsx @@ -0,0 +1,300 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Cast, Plus, Trash2, X, Pencil, Check, Globe } from 'lucide-react'; +import { + getInternetRadioStations, createInternetRadioStation, + updateInternetRadioStation, deleteInternetRadioStation, + InternetRadioStation, buildCoverArtUrl, coverArtCacheKey, +} from '../api/subsonic'; +import { usePlayerStore } from '../store/playerStore'; +import CachedImage from '../components/CachedImage'; +import { useTranslation } from 'react-i18next'; +import { open } from '@tauri-apps/plugin-shell'; + +export default function InternetRadio() { + const { t } = useTranslation(); + const { playRadio, stop, currentRadio, isPlaying } = usePlayerStore(); + + const [stations, setStations] = useState([]); + const [loading, setLoading] = useState(true); + + // Add form + const [adding, setAdding] = useState(false); + const [addName, setAddName] = useState(''); + const [addUrl, setAddUrl] = useState(''); + const [addHomepage, setAddHomepage] = useState(''); + const [addSaving, setAddSaving] = useState(false); + const addNameRef = useRef(null); + + // Edit inline + const [editId, setEditId] = useState(null); + const [editName, setEditName] = useState(''); + const [editUrl, setEditUrl] = useState(''); + const [editHomepage, setEditHomepage] = useState(''); + const [editSaving, setEditSaving] = useState(false); + + // Delete confirm + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + useEffect(() => { + getInternetRadioStations() + .then(setStations) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + if (adding) addNameRef.current?.focus(); + }, [adding]); + + const reload = async () => { + const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]); + setStations(list); + }; + + const handleAdd = async () => { + if (!addName.trim() || !addUrl.trim()) return; + setAddSaving(true); + try { + await createInternetRadioStation(addName.trim(), addUrl.trim(), addHomepage.trim() || undefined); + await reload(); + } catch {} + setAddSaving(false); + setAdding(false); + setAddName(''); setAddUrl(''); setAddHomepage(''); + }; + + const handleEditStart = (s: InternetRadioStation) => { + setEditId(s.id); + setEditName(s.name); + setEditUrl(s.streamUrl); + setEditHomepage(s.homepageUrl ?? ''); + setDeleteConfirmId(null); + }; + + const handleEditSave = async () => { + if (!editId || !editName.trim() || !editUrl.trim()) return; + setEditSaving(true); + try { + await updateInternetRadioStation(editId, editName.trim(), editUrl.trim(), editHomepage.trim() || undefined); + await reload(); + } catch {} + setEditSaving(false); + setEditId(null); + }; + + const handleDelete = async (e: React.MouseEvent, s: InternetRadioStation) => { + e.stopPropagation(); + if (deleteConfirmId !== s.id) { + setDeleteConfirmId(s.id); + return; + } + if (currentRadio?.id === s.id) stop(); + try { + await deleteInternetRadioStation(s.id); + setStations(prev => prev.filter(st => st.id !== s.id)); + } catch {} + setDeleteConfirmId(null); + }; + + const handlePlay = (e: React.MouseEvent, s: InternetRadioStation) => { + e.stopPropagation(); + if (currentRadio?.id === s.id && isPlaying) { + stop(); + } else { + playRadio(s); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ + {/* ── Header ── */} +
+

{t('radio.title')}

+
+ {adding ? ( +
+ setAddName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') { setAdding(false); setAddName(''); setAddUrl(''); setAddHomepage(''); } }} + /> + setAddUrl(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') { setAdding(false); setAddName(''); setAddUrl(''); setAddHomepage(''); } }} + /> + setAddHomepage(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') { setAdding(false); setAddName(''); setAddUrl(''); setAddHomepage(''); } }} + /> + + +
+ ) : ( + + )} +
+
+ + {/* ── Grid ── */} + {stations.length === 0 ? ( +
{t('radio.empty')}
+ ) : ( +
+ {stations.map(s => { + const isActive = currentRadio?.id === s.id; + const isEditingThis = editId === s.id; + + return ( +
{ if (deleteConfirmId === s.id) setDeleteConfirmId(null); }} + > + {/* Cover area */} +
+ {s.coverArt ? ( + + ) : ( +
+ +
+ )} + + {/* LIVE badge on active station */} + {isActive && isPlaying && ( +
+ {t('radio.live')} +
+ )} + + {/* Play overlay */} +
+ +
+ + {/* Delete button */} + +
+ + {/* Info / inline edit */} +
+ {isEditingThis ? ( +
+ setEditName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleEditSave(); if (e.key === 'Escape') setEditId(null); }} + autoFocus + /> + setEditUrl(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleEditSave(); if (e.key === 'Escape') setEditId(null); }} + /> + setEditHomepage(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleEditSave(); if (e.key === 'Escape') setEditId(null); }} + /> +
+ + +
+
+ ) : ( + <> +
+ {s.name} + + {s.homepageUrl && ( + + )} +
+
+ {s.streamUrl} +
+ + )} +
+
+ ); + })} +
+ )} +
+ ); +}