diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index e7dff109..6bbcf91a 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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 = {}; const starred = new Set(); - songs.forEach(s => { + filteredSongs.forEach(s => { if (s.userRating) init[s.id] = s.userRating; if (s.starred) starred.add(s.id); }); diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 1bfa4452..cc3970fe 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -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([]); + const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState>({}); const [playingId, setPlayingId] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const nameInputRef = useRef(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(); + 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 = {}; + 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) && ( - + {!selectionMode && ( +
+ + {isPlaylistDeletable(pl) && ( + + )} +
)} {selectionMode && (
@@ -650,7 +696,24 @@ export default function Playlists() { )} {/* Cover area — server collage or fallback icon */}
- {pl.coverArt ? ( + {isSmartPlaylistName(pl.name) && (smartCoverIdsByPlaylist[pl.id]?.length ?? 0) > 0 ? ( +
+ {Array.from({ length: 4 }, (_, i) => { + const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length]; + return id ? ( + + ) : ( +
+ ); + })} +
+ ) : pl.coverArt ? (