mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
82c414d7bc
* fix(playlists): Smart Playlist editor theme, toggles, and exclude-all-genres Replace native sort select with CustomSelect, fix mode-button layout shift, color-code included vs excluded genres, collapse exclude-all to untagged rule, and handle empty smart playlists without false "not found". * docs: CHANGELOG and credits for Smart Playlist editor fix (PR #970) * chore(credits): drop minor fix entries from PR #958 onward
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
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<SmartFilters>(defaultSmartFilters);
|
|
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
|
const [genreQuery, setGenreQuery] = useState('');
|
|
const [creatingSmartBusy, setCreatingSmartBusy] = useState(false);
|
|
const [editingSmartId, setEditingSmartId] = useState<string | null>(null);
|
|
const [pendingSmart, setPendingSmart] = useState<PendingSmartPlaylist[]>([]);
|
|
const smartCoverIdsByPlaylist = useSmartCoverCollage(playlists, musicLibraryFilterVersion);
|
|
const { filteredSongCountByPlaylist, filteredDurationByPlaylist } =
|
|
usePlaylistsLibraryScopeCounts(playlists, musicLibraryFilterVersion);
|
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
const nameInputRef = useRef<HTMLInputElement>(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 (
|
|
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="content-body animate-fade-in">
|
|
<style>{`
|
|
.dual-year-range {
|
|
position: relative;
|
|
height: 34px;
|
|
}
|
|
.dual-year-range__track,
|
|
.dual-year-range__selected {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: 50%;
|
|
height: 4px;
|
|
transform: translateY(-50%);
|
|
border-radius: 999px;
|
|
}
|
|
.dual-year-range__track { background: var(--border); }
|
|
.dual-year-range__selected { background: var(--accent); }
|
|
.dual-year-range input[type='range'] {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 34px;
|
|
margin: 0;
|
|
background: transparent;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
pointer-events: none;
|
|
}
|
|
.dual-year-range input[type='range']::-webkit-slider-runnable-track { height: 4px; background: transparent; }
|
|
.dual-year-range input[type='range']::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 14px;
|
|
height: 14px;
|
|
margin-top: -5px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border);
|
|
background: var(--bg-card);
|
|
pointer-events: auto;
|
|
cursor: pointer;
|
|
}
|
|
`}</style>
|
|
|
|
<PlaylistsHeader
|
|
selectionMode={selectionMode}
|
|
selectedIds={selectedIds}
|
|
selectedPlaylists={selectedPlaylists}
|
|
isPlaylistDeletable={isPlaylistDeletable}
|
|
toggleSelectionMode={toggleSelectionMode}
|
|
handleDeleteSelected={handleDeleteSelected}
|
|
creating={creating}
|
|
setCreating={setCreating}
|
|
setCreatingSmart={setCreatingSmart}
|
|
newName={newName}
|
|
setNewName={setNewName}
|
|
nameInputRef={nameInputRef}
|
|
handleCreate={handleCreate}
|
|
isNavidromeServer={isNavidromeServer}
|
|
setEditingSmartId={setEditingSmartId}
|
|
setSmartFilters={setSmartFilters}
|
|
setGenreQuery={setGenreQuery}
|
|
/>
|
|
|
|
{creatingSmart && (
|
|
<PlaylistsSmartEditor
|
|
smartFilters={smartFilters}
|
|
setSmartFilters={setSmartFilters}
|
|
availableGenres={availableGenres}
|
|
genreQuery={genreQuery}
|
|
setGenreQuery={setGenreQuery}
|
|
editingSmartId={editingSmartId}
|
|
creatingSmartBusy={creatingSmartBusy}
|
|
setCreatingSmart={setCreatingSmart}
|
|
setEditingSmartId={setEditingSmartId}
|
|
onSave={handleCreateSmart}
|
|
/>
|
|
)}
|
|
|
|
{/* ── Grid ── */}
|
|
{playlists.length === 0 ? (
|
|
<div className="empty-state">{t('playlists.empty')}</div>
|
|
) : (
|
|
<VirtualCardGrid
|
|
items={playlists}
|
|
itemKey={(pl, _i) => pl.id}
|
|
rowVariant="playlist"
|
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
|
layoutSignal={playlists.length}
|
|
renderItem={pl => (
|
|
<PlaylistCard
|
|
pl={pl}
|
|
selectionMode={selectionMode}
|
|
selectedIds={selectedIds}
|
|
selectedPlaylists={selectedPlaylists}
|
|
toggleSelect={toggleSelect}
|
|
isPlaylistDeletable={isPlaylistDeletable}
|
|
deleteConfirmId={deleteConfirmId}
|
|
setDeleteConfirmId={setDeleteConfirmId}
|
|
handleOpenSmartEditor={handleOpenSmartEditor}
|
|
handleDelete={handleDelete}
|
|
handlePlay={handlePlay}
|
|
playingId={playingId}
|
|
smartCoverIdsByPlaylist={smartCoverIdsByPlaylist}
|
|
pendingSmart={pendingSmart}
|
|
filteredSongCountByPlaylist={filteredSongCountByPlaylist}
|
|
filteredDurationByPlaylist={filteredDurationByPlaylist}
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
|
|
</div>
|
|
);
|
|
}
|