Files
psysonic/src/pages/Playlists.tsx
T
Frank Stellmacher 7482030a6b refactor(playlists): G.81 — extract PlaylistsHeader + PlaylistCard + action utilities (cluster) (#648)
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.
2026-05-13 16:17:44 +02:00

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>
);
}