mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
cb1a110afb
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
store/<x> -> features/playback/store/<x>
store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
utils/playback/<x> -> features/playback/utils/playback/<x>
utils/audio/<x> -> features/playback/utils/audio/<x>
184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).
KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).
Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).
tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
import { getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation, uploadRadioCoverArt, deleteRadioCoverArt } from '@/lib/api/subsonicRadio';
|
||
import { type InternetRadioStation } from '@/lib/api/subsonicTypes';
|
||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||
import { Plus, Search } from 'lucide-react';
|
||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||
import { setRadioVolume } from '@/features/playback/store/radioPlayer';
|
||
import { fadeOut } from '@/features/playback/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 effect’s 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 ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
|