import { getPlaylist } from '../api/subsonicPlaylists'; import { getGenres } from '../api/subsonicGenres'; import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import React, { useEffect, useState, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { usePlayerStore } from '../store/playerStore'; import { usePlaylistStore } from '../store/playlistStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { useRangeSelection } from '../hooks/useRangeSelection'; import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration'; import { defaultSmartFilters, isSmartPlaylistName, type SmartFilters, type PendingSmartPlaylist, } from '../utils/playlist/playlistsSmart'; import { useSmartCoverCollage } from '../hooks/useSmartCoverCollage'; import { usePlaylistsLibraryScopeCounts } from '../hooks/usePlaylistsLibraryScopeCounts'; import { usePendingSmartPolling } from '../hooks/usePendingSmartPolling'; import { runPlaylistsOpenSmartEditor } from '../utils/playlist/runPlaylistsOpenSmartEditor'; import { runPlaylistsSaveSmart } from '../utils/playlist/runPlaylistsSaveSmart'; import { runPlaylistDelete, runPlaylistDeleteSelected, runPlaylistMergeSelected, } from '../utils/playlist/runPlaylistsActions'; import PlaylistsSmartEditor from '../components/playlists/PlaylistsSmartEditor'; import PlaylistsHeader from '../components/playlists/PlaylistsHeader'; import PlaylistCard from '../components/playlists/PlaylistCard'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; function formatDuration(seconds: number): string { return formatHumanHoursMinutes(seconds); } export default function Playlists() { const { t } = useTranslation(); const perfFlags = usePerfProbeFlags(); const navigate = useNavigate(); const playTrack = usePlayerStore(s => s.playTrack); const openContextMenu = usePlayerStore(s => s.openContextMenu); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const removeId = usePlaylistStore((s) => s.removeId); const playlists = usePlaylistStore((s) => s.playlists); const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists); const playlistsLoading = usePlaylistStore((s) => s.playlistsLoading); const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? ''); const activeServerId = useAuthStore(s => s.activeServerId); const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [creatingSmart, setCreatingSmart] = useState(false); const [newName, setNewName] = useState(''); const [smartFilters, setSmartFilters] = useState(defaultSmartFilters); const [genres, setGenres] = useState([]); const [genreQuery, setGenreQuery] = useState(''); const [creatingSmartBusy, setCreatingSmartBusy] = useState(false); const [editingSmartId, setEditingSmartId] = useState(null); const [pendingSmart, setPendingSmart] = useState([]); const smartCoverIdsByPlaylist = useSmartCoverCollage(playlists, musicLibraryFilterVersion); const { filteredSongCountByPlaylist, filteredDurationByPlaylist } = usePlaylistsLibraryScopeCounts(playlists, musicLibraryFilterVersion); const [playingId, setPlayingId] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const nameInputRef = useRef(null); // ── Multi-selection ────────────────────────────────────────────────────── const [selectionMode, setSelectionMode] = useState(false); const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(playlists); const isNavidromeServer = Boolean( activeServerId && (subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome', ); const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); }; const clearSelection = () => { setSelectionMode(false); resetSelection(); }; const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id)); const isPlaylistDeletable = useCallback((pl: SubsonicPlaylist) => { if (!pl.owner) return true; if (!activeUsername) return false; return pl.owner === activeUsername; }, [activeUsername]); useEffect(() => { fetchPlaylists().finally(() => setLoading(false)); getGenres().then(setGenres).catch(() => {}); }, [fetchPlaylists]); useEffect(() => { if (creating) nameInputRef.current?.focus(); }, [creating]); const createPlaylist = usePlaylistStore(s => s.createPlaylist); const availableGenres = genres .map(g => g.value) .filter(v => !smartFilters.selectedGenres.includes(v)) .filter(v => !genreQuery.trim() || v.toLowerCase().includes(genreQuery.trim().toLowerCase())) .sort((a, b) => a.localeCompare(b)); const handleCreate = async () => { const name = newName.trim() || t('playlists.unnamed'); await createPlaylist(name); // Refresh playlists from API to get the new one await fetchPlaylists(); setCreating(false); setNewName(''); }; const handleOpenSmartEditor = (pl: SubsonicPlaylist) => runPlaylistsOpenSmartEditor({ pl, isNavidromeServer, allGenres: genres, t, setSmartFilters, setEditingSmartId, setGenreQuery, setCreating, setCreatingSmart, setCreatingSmartBusy, }); const handleCreateSmart = () => runPlaylistsSaveSmart({ isNavidromeServer, smartFilters, allGenres: genres.map(g => g.value), editingSmartId, playlists, fetchPlaylists, t, setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters, setGenreQuery, setCreatingSmartBusy, }); // Smart playlist rules are processed asynchronously on server. usePendingSmartPolling(pendingSmart, setPendingSmart, fetchPlaylists); const handlePlay = async (e: React.MouseEvent, pl: SubsonicPlaylist) => { e.stopPropagation(); if (playingId === pl.id) return; setPlayingId(pl.id); try { const data = await getPlaylist(pl.id); const filteredSongs = await filterSongsToActiveLibrary(data.songs); const tracks = filteredSongs.map(songToTrack); if (tracks.length > 0) { touchPlaylist(pl.id); playTrack(tracks[0], tracks); } } catch {} setPlayingId(null); }; const handleDelete = (e: React.MouseEvent, pl: SubsonicPlaylist) => runPlaylistDelete({ e, pl, deleteConfirmId, setDeleteConfirmId, removeId, t, }); const handleDeleteSelected = () => runPlaylistDeleteSelected({ selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t, }); const handleMergeSelected = (targetPlaylist: SubsonicPlaylist) => runPlaylistMergeSelected({ targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t, }); if (loading) { return (
); } return (
{creatingSmart && ( )} {/* ── Grid ── */} {playlists.length === 0 ? (
{t('playlists.empty')}
) : ( pl.id} rowVariant="playlist" disableVirtualization={perfFlags.disableMainstageVirtualLists} layoutSignal={playlists.length} renderItem={pl => ( )} /> )}
); }