mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
7482030a6b
Three-cut cluster closing out the Playlists refactor. 516 → 273 LOC
(−243).
runPlaylistsActions — runPlaylistDelete (two-click confirm with
tooltip re-trigger), runPlaylistDeleteSelected (filters by
deletable, refreshes store, fires per-row error toasts), and
runPlaylistMergeSelected (collects unique songs across selected
playlists into the target, updatePlaylist + touchPlaylist + total
count toast). Each takes a deps object so all state-setters /
callbacks are explicit.
PlaylistsHeader — title row + creation controls (inline name
input with Enter / Escape handling, "New playlist" button,
"New smart" button gated on isNavidromeServer) + bulk delete
button + selection-mode toggle. The selection-mode title swaps
between t('playlists.title') and t('playlists.selectionCount').
PlaylistCard — full single-card render: cover area (smart-playlist
2×2 collage / cover image / fallback ListMusic icon + pending
clock badge), hover-only edit + delete buttons (delete with
two-click confirm), selection check overlay, play overlay button
with spinner state, and the info row (smart-playlist sparkle +
display name + song count + duration). Subscribes to
playerStore.openContextMenu directly.
Playlists drops the inline definitions + the now-unused direct
imports (deletePlaylist / updatePlaylist, buildCoverArtUrl /
coverArtCacheKey, CachedImage, StarRating, the cover image
helpers, most lucide icons, useMemo). Pure code move otherwise.
283 lines
11 KiB
TypeScript
283 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/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/formatHumanDuration';
|
|
import {
|
|
defaultSmartFilters, isSmartPlaylistName,
|
|
type SmartFilters, type PendingSmartPlaylist,
|
|
} from '../utils/playlistsSmart';
|
|
import { useSmartCoverCollage } from '../hooks/useSmartCoverCollage';
|
|
import { usePlaylistsLibraryScopeCounts } from '../hooks/usePlaylistsLibraryScopeCounts';
|
|
import { usePendingSmartPolling } from '../hooks/usePendingSmartPolling';
|
|
import { runPlaylistsOpenSmartEditor } from '../utils/runPlaylistsOpenSmartEditor';
|
|
import { runPlaylistsSaveSmart } from '../utils/runPlaylistsSaveSmart';
|
|
import {
|
|
runPlaylistDelete, runPlaylistDeleteSelected, runPlaylistMergeSelected,
|
|
} from '../utils/runPlaylistsActions';
|
|
import PlaylistsSmartEditor from '../components/playlists/PlaylistsSmartEditor';
|
|
import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
|
|
import PlaylistCard from '../components/playlists/PlaylistCard';
|
|
|
|
function formatDuration(seconds: number): string {
|
|
return formatHumanHoursMinutes(seconds);
|
|
}
|
|
|
|
export default function Playlists() {
|
|
const { t } = useTranslation();
|
|
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, t,
|
|
setSmartFilters, setEditingSmartId, setGenreQuery,
|
|
setCreating, setCreatingSmart, setCreatingSmartBusy,
|
|
});
|
|
|
|
const handleCreateSmart = () => runPlaylistsSaveSmart({
|
|
isNavidromeServer, smartFilters, 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>
|
|
) : (
|
|
<div className="album-grid-wrap">
|
|
{playlists.map((pl) => (
|
|
<PlaylistCard
|
|
key={pl.id}
|
|
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>
|
|
)}
|
|
|
|
|
|
</div>
|
|
);
|
|
}
|