fix(playlists): refine smart playlist UX and library-scoped tracks

Show edit/delete actions on hover in playlist cards, support opening metadata edit directly from the grid, and render smart playlist covers from active-library tracks. Playlist detail now filters displayed songs to the selected library instead of hiding whole playlists.
This commit is contained in:
Maxim Isaev
2026-04-24 17:09:59 +03:00
parent 27a59f6b23
commit 9baf01fdc2
3 changed files with 121 additions and 40 deletions
+15 -5
View File
@@ -1,13 +1,13 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles } from 'lucide-react'; import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import { import {
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt, getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
search, setRating, star, unstar, search, setRating, star, unstar,
getRandomSongs, buildDownloadUrl, SubsonicPlaylist, SubsonicSong, getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong,
} from '../api/subsonic'; } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@@ -237,6 +237,7 @@ export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore( const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
useShallow(s => ({ useShallow(s => ({
playTrack: s.playTrack, playTrack: s.playTrack,
@@ -413,6 +414,14 @@ export default function PlaylistDetail() {
if (!contextMenuOpen) setContextMenuSongId(null); if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]); }, [contextMenuOpen]);
useEffect(() => {
const state = (location.state as { openEditMeta?: boolean } | null) ?? null;
if (state?.openEditMeta) {
setEditingMeta(true);
navigate(location.pathname, { replace: true, state: null });
}
}, [location.state, location.pathname, navigate]);
// ── Load ───────────────────────────────────────────────────── // ── Load ─────────────────────────────────────────────────────
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined)); const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
@@ -420,13 +429,14 @@ export default function PlaylistDetail() {
if (!id) return; if (!id) return;
setLoading(true); setLoading(true);
getPlaylist(id) getPlaylist(id)
.then(({ playlist, songs }) => { .then(async ({ playlist, songs }) => {
const filteredSongs = await filterSongsToActiveLibrary(songs);
setPlaylist(playlist); setPlaylist(playlist);
setSongs(songs); setSongs(filteredSongs);
if (playlist.coverArt) setCustomCoverId(playlist.coverArt); if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
const init: Record<string, number> = {}; const init: Record<string, number> = {};
const starred = new Set<string>(); const starred = new Set<string>();
songs.forEach(s => { filteredSongs.forEach(s => {
if (s.userRating) init[s.id] = s.userRating; if (s.userRating) init[s.id] = s.userRating;
if (s.starred) starred.add(s.id); if (s.starred) starred.add(s.id);
}); });
+82 -19
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles } from 'lucide-react'; import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react';
import { deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, SubsonicGenre } from '../api/subsonic'; import { deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, SubsonicGenre, filterSongsToActiveLibrary } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore'; import { usePlaylistStore } from '../store/playlistStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -92,6 +92,7 @@ export default function Playlists() {
const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? ''); const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const activeServerId = useAuthStore(s => s.activeServerId); const activeServerId = useAuthStore(s => s.activeServerId);
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer); const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@@ -102,6 +103,7 @@ export default function Playlists() {
const [genreQuery, setGenreQuery] = useState(''); const [genreQuery, setGenreQuery] = useState('');
const [creatingSmartBusy, setCreatingSmartBusy] = useState(false); const [creatingSmartBusy, setCreatingSmartBusy] = useState(false);
const [pendingSmart, setPendingSmart] = useState<PendingSmartPlaylist[]>([]); const [pendingSmart, setPendingSmart] = useState<PendingSmartPlaylist[]>([]);
const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState<Record<string, string[]>>({});
const [playingId, setPlayingId] = useState<string | null>(null); const [playingId, setPlayingId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null); const nameInputRef = useRef<HTMLInputElement>(null);
@@ -144,6 +146,44 @@ export default function Playlists() {
getGenres().then(setGenres).catch(() => {}); getGenres().then(setGenres).catch(() => {});
}, [fetchPlaylists]); }, [fetchPlaylists]);
// Smart playlists: build 2x2 cover collage from tracks inside the active library scope.
useEffect(() => {
let cancelled = false;
const run = async () => {
const smart = playlists.filter(pl => isSmartPlaylistName(pl.name));
if (smart.length === 0) {
if (!cancelled) setSmartCoverIdsByPlaylist({});
return;
}
const rows = await Promise.all(
smart.map(async (pl) => {
try {
const { songs } = await getPlaylist(pl.id);
const filtered = await filterSongsToActiveLibrary(songs);
const ids: string[] = [];
const seen = new Set<string>();
for (const s of filtered) {
const cid = s.coverArt;
if (!cid || seen.has(cid)) continue;
seen.add(cid);
ids.push(cid);
if (ids.length >= 4) break;
}
return [pl.id, ids] as const;
} catch {
return [pl.id, [] as string[]] as const;
}
}),
);
if (cancelled) return;
const next: Record<string, string[]> = {};
for (const [id, ids] of rows) next[id] = ids;
setSmartCoverIdsByPlaylist(next);
};
run();
return () => { cancelled = true; };
}, [playlists, musicLibraryFilterVersion]);
useEffect(() => { useEffect(() => {
if (creating) nameInputRef.current?.focus(); if (creating) nameInputRef.current?.focus();
}, [creating]); }, [creating]);
@@ -626,22 +666,28 @@ export default function Playlists() {
borderRadius: 'var(--radius-md)' borderRadius: 'var(--radius-md)'
} : { position: 'relative' }} } : { position: 'relative' }}
> >
{!selectionMode && isPlaylistDeletable(pl) && ( {!selectionMode && (
<button <div className="playlist-card-actions">
className="btn btn-ghost" <button
onClick={(e) => handleDelete(e, pl)} className="playlist-card-action playlist-card-action--edit"
style={{ onClick={(e) => {
position: 'absolute', e.stopPropagation();
top: 8, navigate(`/playlists/${pl.id}`, { state: { openEditMeta: true } });
right: 8, }}
zIndex: 5, data-tooltip={t('playlists.editMeta')}
padding: 4, >
borderColor: deleteConfirmId === pl.id ? 'var(--color-danger, #e66)' : undefined, <Pencil size={13} />
}} </button>
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('common.delete')} {isPlaylistDeletable(pl) && (
> <button
<Trash2 size={14} /> className={`playlist-card-action playlist-card-action--delete${deleteConfirmId === pl.id ? ' playlist-card-action--delete-confirm' : ''}`}
</button> onClick={(e) => handleDelete(e, pl)}
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('common.delete')}
>
<Trash2 size={13} />
</button>
)}
</div>
)} )}
{selectionMode && ( {selectionMode && (
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}> <div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
@@ -650,7 +696,24 @@ export default function Playlists() {
)} )}
{/* Cover area — server collage or fallback icon */} {/* Cover area — server collage or fallback icon */}
<div className="album-card-cover"> <div className="album-card-cover">
{pl.coverArt ? ( {isSmartPlaylistName(pl.name) && (smartCoverIdsByPlaylist[pl.id]?.length ?? 0) > 0 ? (
<div className="playlist-cover-grid">
{Array.from({ length: 4 }, (_, i) => {
const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length];
return id ? (
<CachedImage
key={i}
className="playlist-cover-cell"
src={buildCoverArtUrl(id, 200)}
cacheKey={coverArtCacheKey(id, 200)}
alt=""
/>
) : (
<div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
);
})}
</div>
) : pl.coverArt ? (
<CachedImage <CachedImage
src={buildCoverArtUrl(pl.coverArt, 256)} src={buildCoverArtUrl(pl.coverArt, 256)}
cacheKey={coverArtCacheKey(pl.coverArt, 256)} cacheKey={coverArtCacheKey(pl.coverArt, 256)}
+24 -16
View File
@@ -7286,43 +7286,51 @@ html.no-compositing .fs-seekbar-played {
opacity: 0.6; opacity: 0.6;
} }
/* Delete button — top-right corner of card cover, hidden until hover */ /* Playlist card actions (edit/delete) — visible on hover */
.playlist-card-delete { .playlist-card-actions {
position: absolute; position: absolute;
top: 6px; top: 8px;
right: 6px; right: 8px;
display: flex;
gap: 6px;
opacity: 0;
transform: translateY(-2px);
transition: opacity var(--transition-fast), transform var(--transition-fast);
z-index: 6;
}
.album-card:hover .playlist-card-actions {
opacity: 1;
transform: translateY(0);
}
.playlist-card-action {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
border: none; border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.55); background: rgba(0, 0, 0, 0.55);
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast); transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
z-index: 2;
} }
.album-card:hover .playlist-card-delete { .playlist-card-action--edit:hover {
opacity: 1; background: rgba(90, 120, 255, 0.85);
color: #fff;
} }
.playlist-card-delete:hover { .playlist-card-action--delete:hover {
background: rgba(220, 60, 60, 0.85); background: rgba(220, 60, 60, 0.85);
color: #fff; color: #fff;
} }
.playlist-card-delete--confirm { .playlist-card-action--delete-confirm {
background: #dc3c3c !important; background: #dc3c3c !important;
color: #fff !important; color: #fff !important;
opacity: 1 !important;
width: 30px !important;
height: 30px !important;
top: 3px !important;
right: 3px !important;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.45); box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.45);
animation: delete-confirm-pulse 0.7s ease-in-out infinite alternate; animation: delete-confirm-pulse 0.7s ease-in-out infinite alternate;
} }