mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -1,13 +1,13 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
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 { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
|
||||
search, setRating, star, unstar,
|
||||
getRandomSongs, buildDownloadUrl, SubsonicPlaylist, SubsonicSong,
|
||||
getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
@@ -237,6 +237,7 @@ export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
playTrack: s.playTrack,
|
||||
@@ -413,6 +414,14 @@ export default function PlaylistDetail() {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [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 ─────────────────────────────────────────────────────
|
||||
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
||||
|
||||
@@ -420,13 +429,14 @@ export default function PlaylistDetail() {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getPlaylist(id)
|
||||
.then(({ playlist, songs }) => {
|
||||
.then(async ({ playlist, songs }) => {
|
||||
const filteredSongs = await filterSongsToActiveLibrary(songs);
|
||||
setPlaylist(playlist);
|
||||
setSongs(songs);
|
||||
setSongs(filteredSongs);
|
||||
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
|
||||
const init: Record<string, number> = {};
|
||||
const starred = new Set<string>();
|
||||
songs.forEach(s => {
|
||||
filteredSongs.forEach(s => {
|
||||
if (s.userRating) init[s.id] = s.userRating;
|
||||
if (s.starred) starred.add(s.id);
|
||||
});
|
||||
|
||||
+82
-19
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles } from 'lucide-react';
|
||||
import { deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, SubsonicGenre } from '../api/subsonic';
|
||||
import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react';
|
||||
import { deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, SubsonicGenre, filterSongsToActiveLibrary } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -92,6 +92,7 @@ export default function Playlists() {
|
||||
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);
|
||||
@@ -102,6 +103,7 @@ export default function Playlists() {
|
||||
const [genreQuery, setGenreQuery] = useState('');
|
||||
const [creatingSmartBusy, setCreatingSmartBusy] = useState(false);
|
||||
const [pendingSmart, setPendingSmart] = useState<PendingSmartPlaylist[]>([]);
|
||||
const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState<Record<string, string[]>>({});
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -144,6 +146,44 @@ export default function Playlists() {
|
||||
getGenres().then(setGenres).catch(() => {});
|
||||
}, [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(() => {
|
||||
if (creating) nameInputRef.current?.focus();
|
||||
}, [creating]);
|
||||
@@ -626,22 +666,28 @@ export default function Playlists() {
|
||||
borderRadius: 'var(--radius-md)'
|
||||
} : { position: 'relative' }}
|
||||
>
|
||||
{!selectionMode && isPlaylistDeletable(pl) && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => handleDelete(e, pl)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 5,
|
||||
padding: 4,
|
||||
borderColor: deleteConfirmId === pl.id ? 'var(--color-danger, #e66)' : undefined,
|
||||
}}
|
||||
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('common.delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{!selectionMode && (
|
||||
<div className="playlist-card-actions">
|
||||
<button
|
||||
className="playlist-card-action playlist-card-action--edit"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/playlists/${pl.id}`, { state: { openEditMeta: true } });
|
||||
}}
|
||||
data-tooltip={t('playlists.editMeta')}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
{isPlaylistDeletable(pl) && (
|
||||
<button
|
||||
className={`playlist-card-action playlist-card-action--delete${deleteConfirmId === pl.id ? ' playlist-card-action--delete-confirm' : ''}`}
|
||||
onClick={(e) => handleDelete(e, pl)}
|
||||
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('common.delete')}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectionMode && (
|
||||
<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 */}
|
||||
<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
|
||||
src={buildCoverArtUrl(pl.coverArt, 256)}
|
||||
cacheKey={coverArtCacheKey(pl.coverArt, 256)}
|
||||
|
||||
+24
-16
@@ -7286,43 +7286,51 @@ html.no-compositing .fs-seekbar-played {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Delete button — top-right corner of card cover, hidden until hover */
|
||||
.playlist-card-delete {
|
||||
/* Playlist card actions (edit/delete) — visible on hover */
|
||||
.playlist-card-actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
top: 8px;
|
||||
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;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.album-card:hover .playlist-card-delete {
|
||||
opacity: 1;
|
||||
.playlist-card-action--edit:hover {
|
||||
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);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playlist-card-delete--confirm {
|
||||
.playlist-card-action--delete-confirm {
|
||||
background: #dc3c3c !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);
|
||||
animation: delete-confirm-pulse 0.7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user