import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react'; import { deletePlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, filterSongsToActiveLibrary } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { usePlaylistStore } from '../store/playlistStore'; import { useAuthStore } from '../store/authStore'; import CachedImage from '../components/CachedImage'; import StarRating from '../components/StarRating'; import { useTranslation } from 'react-i18next'; import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import { showToast } from '../utils/toast'; import { ndCreateSmartPlaylist, ndGetSmartPlaylist, ndListSmartPlaylists, ndUpdateSmartPlaylist } from '../api/navidromeSmart'; import { useRangeSelection } from '../hooks/useRangeSelection'; function formatDuration(seconds: number): string { return formatHumanHoursMinutes(seconds); } function PlaylistSmartCoverCell({ coverId }: { coverId: string }) { const src = useMemo(() => buildCoverArtUrl(coverId, 200), [coverId]); const cacheKey = useMemo(() => coverArtCacheKey(coverId, 200), [coverId]); return ( ); } function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) { const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]); const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]); return ( ); } const SMART_PREFIX = 'psy-smart-'; const LIMIT_MAX = 500; const YEAR_MIN = 1950; const YEAR_MAX = new Date().getFullYear() + 1; type GenreMode = 'include' | 'exclude'; type YearMode = 'include' | 'exclude'; type SmartFilters = { name: string; limit: string; sort: string; artistContains: string; albumContains: string; titleContains: string; minRating: number; excludeUnrated: boolean; compilationOnly: boolean; selectedGenres: string[]; genreMode: GenreMode; yearFrom: number; yearTo: number; yearMode: YearMode; }; type PendingSmartPlaylist = { name: string; id?: string; firstSeenCoverArt?: string; attempts: number; }; type NdSmartRuleNode = Record; const defaultSmartFilters: SmartFilters = { name: '', limit: '50', sort: '+random', artistContains: '', albumContains: '', titleContains: '', minRating: 0, excludeUnrated: false, compilationOnly: false, selectedGenres: [], genreMode: 'include', yearFrom: YEAR_MIN, yearTo: YEAR_MAX, yearMode: 'include', }; function clampYear(v: number): number { return Math.max(YEAR_MIN, Math.min(YEAR_MAX, v)); } function isSmartPlaylistName(name: string): boolean { return (name ?? '').toLowerCase().startsWith(SMART_PREFIX); } function displayPlaylistName(name: string): string { const n = name ?? ''; if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length); return n; } function asRecord(v: unknown): Record | null { return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; } function parseSmartRulesToFilters( rules: Record | undefined, playlistName: string, ): SmartFilters { const next: SmartFilters = { ...defaultSmartFilters, name: displayPlaylistName(playlistName), }; if (!rules) return next; if (typeof rules.limit === 'number' && Number.isFinite(rules.limit)) { next.limit = String(Math.max(1, Math.min(LIMIT_MAX, Number(rules.limit)))); } if (typeof rules.sort === 'string' && rules.sort.trim()) next.sort = rules.sort; const includeGenres: string[] = []; const excludeGenres: string[] = []; const all = Array.isArray(rules.all) ? rules.all : []; for (const node of all) { const obj = asRecord(node); if (!obj) continue; const contains = asRecord(obj.contains); if (contains) { if (typeof contains.artist === 'string') next.artistContains = contains.artist; if (typeof contains.album === 'string') next.albumContains = contains.album; if (typeof contains.title === 'string') next.titleContains = contains.title; } const gt = asRecord(obj.gt); if (gt && typeof gt.rating === 'number') { if (gt.rating > 0) next.minRating = Math.max(0, Math.min(5, Math.floor(gt.rating))); else if (gt.rating === 0) next.excludeUnrated = true; } const is = asRecord(obj.is); if (is?.compilation === true) next.compilationOnly = true; const notContains = asRecord(obj.notContains); if (notContains && typeof notContains.genre === 'string') excludeGenres.push(notContains.genre); const inTheRange = asRecord(obj.inTheRange); if (inTheRange && Array.isArray(inTheRange.year) && inTheRange.year.length === 2) { const from = Number(inTheRange.year[0]); const to = Number(inTheRange.year[1]); if (Number.isFinite(from) && Number.isFinite(to)) { next.yearMode = 'include'; next.yearFrom = clampYear(Math.min(from, to)); next.yearTo = clampYear(Math.max(from, to)); } } const any = Array.isArray(obj.any) ? (obj.any as NdSmartRuleNode[]) : []; if (any.length > 0) { const parsedGenreIncludes = any .map((item) => asRecord(asRecord(item)?.contains)?.genre) .filter((v): v is string => typeof v === 'string'); if (parsedGenreIncludes.length > 0) includeGenres.push(...parsedGenreIncludes); const ltYear = any.map((item) => asRecord(asRecord(item)?.lt)?.year).find((v) => typeof v === 'number'); const gtYear = any.map((item) => asRecord(asRecord(item)?.gt)?.year).find((v) => typeof v === 'number'); if (typeof ltYear === 'number' && typeof gtYear === 'number') { next.yearMode = 'exclude'; next.yearFrom = clampYear(Math.min(ltYear, gtYear)); next.yearTo = clampYear(Math.max(ltYear, gtYear)); } } } if (includeGenres.length > 0) { next.genreMode = 'include'; next.selectedGenres = [...new Set(includeGenres)]; } else if (excludeGenres.length > 0) { next.genreMode = 'exclude'; next.selectedGenres = [...new Set(excludeGenres)]; } return next; } 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(defaultSmartFilters); const [genres, setGenres] = useState([]); const [genreQuery, setGenreQuery] = useState(''); const [creatingSmartBusy, setCreatingSmartBusy] = useState(false); const [editingSmartId, setEditingSmartId] = useState(null); const [pendingSmart, setPendingSmart] = useState([]); const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState>({}); const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState>({}); const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState>({}); const [playingId, setPlayingId] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const nameInputRef = useRef(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]); // 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]); // Playlist list should reflect active library scope for song counts. useEffect(() => { let cancelled = false; const run = async () => { if (playlists.length === 0) { if (!cancelled) { setFilteredSongCountByPlaylist({}); setFilteredDurationByPlaylist({}); } return; } const ids = playlists.map((pl) => pl.id); const next: Record = {}; const nextDuration: Record = {}; for (let i = 0; i < ids.length; i += 4) { const chunk = ids.slice(i, i + 4); const rows = await Promise.all( chunk.map(async (id) => { try { const { songs } = await getPlaylist(id); const filtered = await filterSongsToActiveLibrary(songs); const duration = filtered.reduce((acc, s) => acc + (s.duration ?? 0), 0); return [id, filtered.length, duration] as const; } catch { return [id, -1, -1] as const; } }), ); for (const [id, count, duration] of rows) { if (count >= 0) next[id] = count; if (duration >= 0) nextDuration[id] = duration; } } if (!cancelled) { setFilteredSongCountByPlaylist(next); setFilteredDurationByPlaylist(nextDuration); } }; run(); return () => { cancelled = true; }; }, [playlists, musicLibraryFilterVersion]); 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 buildSmartRulesPayload = (): Record => { const all: Record[] = []; if (smartFilters.artistContains.trim()) all.push({ contains: { artist: smartFilters.artistContains.trim() } }); if (smartFilters.albumContains.trim()) all.push({ contains: { album: smartFilters.albumContains.trim() } }); if (smartFilters.titleContains.trim()) all.push({ contains: { title: smartFilters.titleContains.trim() } }); const minRating = Number(smartFilters.minRating); if (Number.isFinite(minRating) && minRating > 0) all.push({ gt: { rating: minRating } }); else if (smartFilters.excludeUnrated) all.push({ gt: { rating: 0 } }); if (smartFilters.compilationOnly) all.push({ is: { compilation: true } }); if (smartFilters.selectedGenres.length > 0) { if (smartFilters.genreMode === 'include') { all.push({ any: smartFilters.selectedGenres.map(v => ({ contains: { genre: v } })) }); } else { for (const g of smartFilters.selectedGenres) all.push({ notContains: { genre: g } }); } } if (smartFilters.yearMode === 'include') { all.push({ inTheRange: { year: [smartFilters.yearFrom, smartFilters.yearTo] } }); } else { all.push({ any: [{ lt: { year: smartFilters.yearFrom } }, { gt: { year: smartFilters.yearTo } }] }); } const rules: Record = { all }; rules.limit = Math.max(1, Math.min(LIMIT_MAX, Number(smartFilters.limit) || 50)); rules.sort = smartFilters.sort; return rules; }; const handleOpenSmartEditor = async (pl: SubsonicPlaylist) => { if (!isNavidromeServer || !isSmartPlaylistName(pl.name)) return; setCreatingSmartBusy(true); try { let target: { id: string; name: string; rules?: Record } | null = null; try { // Prefer direct endpoint for this playlist: returns freshest rules. const direct = await ndGetSmartPlaylist(pl.id); if (direct.id && (direct.rules || isSmartPlaylistName(direct.name))) target = direct; } catch { // Fallback to list endpoint below. } if (!target) { const smart = await ndListSmartPlaylists(); target = smart.find((v) => v.id === pl.id || v.name === pl.name || displayPlaylistName(v.name) === displayPlaylistName(pl.name), ) ?? null; } if (target) { setSmartFilters(parseSmartRulesToFilters(target.rules, target.name)); setEditingSmartId(target.id); } else { // Fallback: allow editing even if Navidrome smart list endpoint // doesn't return this playlist (shared/migrated/legacy edge cases). setSmartFilters({ ...defaultSmartFilters, name: displayPlaylistName(pl.name), }); setEditingSmartId(pl.id); } setGenreQuery(''); setCreating(false); setCreatingSmart(true); } catch { // Degrade gracefully instead of blocking the editor on transient/API errors. setSmartFilters({ ...defaultSmartFilters, name: displayPlaylistName(pl.name), }); setGenreQuery(''); setEditingSmartId(pl.id); setCreating(false); setCreatingSmart(true); showToast(t('smartPlaylists.loadFailed'), 3500, 'warning'); } finally { setCreatingSmartBusy(false); } }; const handleCreateSmart = async () => { if (!isNavidromeServer) { showToast(t('smartPlaylists.navidromeOnly'), 3500, 'error'); return; } setCreatingSmartBusy(true); try { let baseName = smartFilters.name.trim() || `mix-${new Date().toISOString().slice(0, 10)}`; if (!editingSmartId) { const existingNames = new Set(playlists.map((p) => (p.name ?? '').toLowerCase())); const requestedBaseName = baseName; let ordinal = 2; while (existingNames.has(`${SMART_PREFIX}${baseName}`.toLowerCase())) { baseName = `${requestedBaseName}-${ordinal}`; ordinal += 1; } } const rules = buildSmartRulesPayload(); const fullName = `${SMART_PREFIX}${baseName}`; if (editingSmartId) { await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true); } else { await ndCreateSmartPlaylist(fullName, rules, true); } await fetchPlaylists(); const createdName = fullName; const updatedId = editingSmartId; setPendingSmart(prev => { const existing = prev.find(p => p.id === updatedId || p.name === createdName); if (existing) return prev; const created = usePlaylistStore.getState().playlists.find((p) => p.id === updatedId || p.name === createdName); return [ ...prev, { name: createdName, id: updatedId ?? created?.id, firstSeenCoverArt: created?.coverArt, attempts: 0, }, ]; }); setCreatingSmart(false); setEditingSmartId(null); setSmartFilters(defaultSmartFilters); setGenreQuery(''); if (updatedId) showToast(t('smartPlaylists.updated', { name: createdName }), 3500, 'success'); else showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success'); } catch { showToast(editingSmartId ? t('smartPlaylists.updateFailed') : t('smartPlaylists.createFailed'), 3500, 'error'); } finally { setCreatingSmartBusy(false); } }; // Smart playlist rules are processed asynchronously on server. // Poll list every 10s and keep waiting through Navidrome placeholder cover. useEffect(() => { if (pendingSmart.length === 0) return; const interval = window.setInterval(async () => { await fetchPlaylists(); const listNow = usePlaylistStore.getState().playlists; const hydrated = pendingSmart.map(item => { if (item.id) return item; const found = listNow.find(p => p.name === item.name); return found ? { ...item, id: found.id } : item; }); // Detail endpoint tends to reflect fresh metadata earlier than list endpoint. const ids = hydrated.map(p => p.id).filter((v): v is string => Boolean(v)); const details = await Promise.all( ids.map(async (id) => { try { const { playlist } = await getPlaylist(id); return playlist; } catch { return null; } }), ); const freshById = new Map( details.filter((p): p is SubsonicPlaylist => p !== null).map(p => [p.id, p]), ); if (freshById.size > 0) { usePlaylistStore.setState((s) => ({ playlists: s.playlists.map((p) => { const fresh = freshById.get(p.id); return fresh ? { ...p, ...fresh } : p; }), })); } const current = usePlaylistStore.getState().playlists; setPendingSmart(() => { const next: PendingSmartPlaylist[] = []; for (const item of hydrated) { const pl = item.id ? current.find(p => p.id === item.id) : current.find(p => p.name === item.name); if (!pl) { next.push({ ...item, attempts: item.attempts + 1 }); continue; } const songCount = pl.songCount ?? 0; const currentCover = pl.coverArt; const firstCover = item.firstSeenCoverArt ?? currentCover; const placeholderStillThere = Boolean(firstCover) && currentCover === firstCover; // Wait until we see actual content and cover changed from the first placeholder-ish cover. // Fallback timeout keeps UI from waiting forever on servers that never update cover id. const hardTimeoutReached = item.attempts >= 18; // ~3 minutes (18 * 10s) const ready = songCount > 0 && (!placeholderStillThere || hardTimeoutReached); if (!ready) { next.push({ ...item, id: pl.id, firstSeenCoverArt: firstCover, attempts: item.attempts + 1, }); } } return next; }); }, 10000); return () => window.clearInterval(interval); }, [pendingSmart, 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 = async (e: React.MouseEvent, pl: SubsonicPlaylist) => { e.stopPropagation(); if (deleteConfirmId !== pl.id) { setDeleteConfirmId(pl.id); const btn = e.currentTarget as HTMLElement; requestAnimationFrame(() => { btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); return; } try { await deletePlaylist(pl.id); removeId(pl.id); usePlaylistStore.setState((s) => ({ playlists: s.playlists.filter((p) => p.id !== pl.id), })); showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info'); } catch { showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); } setDeleteConfirmId(null); }; const handleDeleteSelected = async () => { const deletable = selectedPlaylists.filter(isPlaylistDeletable); if (deletable.length === 0) return; let deleted = 0; for (const pl of deletable) { try { await deletePlaylist(pl.id); removeId(pl.id); deleted++; } catch { showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); } } usePlaylistStore.setState((s) => ({ playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))), })); clearSelection(); if (deleted > 0) { showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info'); } }; const handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => { if (selectedPlaylists.length === 0) return; try { const { songs: targetSongs } = await getPlaylist(targetPlaylist.id); const targetIds = new Set(targetSongs.map(s => s.id)); let totalAdded = 0; for (const pl of selectedPlaylists) { if (pl.id === targetPlaylist.id) continue; const { songs } = await getPlaylist(pl.id); const newSongs = songs.filter(s => !targetIds.has(s.id)); if (newSongs.length > 0) { newSongs.forEach(s => targetIds.add(s.id)); totalAdded += newSongs.length; } } if (totalAdded > 0) { await updatePlaylist(targetPlaylist.id, Array.from(targetIds)); touchPlaylist(targetPlaylist.id); showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info'); } else { showToast(t('playlists.mergeNoNewSongs'), 3000, 'info'); } clearSelection(); } catch { showToast(t('playlists.mergeError'), 4000, 'error'); } }; if (loading) { return (
); } return (
{/* ── Header row ── */}

{selectionMode && selectedIds.size > 0 ? t('playlists.selectionCount', { count: selectedIds.size }) : t('playlists.title')}

{!(selectionMode && selectedIds.size > 0) && (<> {creating ? ( <> setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') { setCreating(false); setNewName(''); } }} /> ) : ( )} {!creating && isNavidromeServer && ( )} )} {selectionMode && selectedIds.size > 0 && (() => { const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length; return ( ); })()}
{creatingSmart && (
{t('smartPlaylists.sectionBasic')}
setSmartFilters(v => ({ ...v, name: e.target.value }))} />
setSmartFilters(v => ({ ...v, limit: e.target.value }))} /> {t('smartPlaylists.limitHint', { max: LIMIT_MAX })}
{t('smartPlaylists.sectionGenres')}
{t('smartPlaylists.genreMode')}
setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} />
{t('smartPlaylists.availableGenres')}
{availableGenres.map(g => ( ))}
{t('smartPlaylists.selectedGenres')}
{smartFilters.selectedGenres.map(g => ( ))}
{t('smartPlaylists.sectionYearsAndFilters')}
{t('smartPlaylists.yearMode')}
{t('smartPlaylists.fromYear')}: {smartFilters.yearFrom} {t('smartPlaylists.toYear')}: {smartFilters.yearTo}
setSmartFilters(v => ({ ...v, yearFrom: Math.min(clampYear(Number(e.target.value)), v.yearTo) }))} /> setSmartFilters(v => ({ ...v, yearTo: Math.max(clampYear(Number(e.target.value)), v.yearFrom) }))} />
setSmartFilters(v => ({ ...v, artistContains: e.target.value }))} /> setSmartFilters(v => ({ ...v, albumContains: e.target.value }))} /> setSmartFilters(v => ({ ...v, titleContains: e.target.value }))} />
{t('smartPlaylists.minRating')}: {smartFilters.minRating}★
setSmartFilters(v => ({ ...v, minRating: rating }))} ariaLabel={t('smartPlaylists.minRatingAria')} /> {t('smartPlaylists.minRatingHint')}
)} {/* ── Grid ── */} {playlists.length === 0 ? (
{t('playlists.empty')}
) : (
{playlists.map((pl) => (
{ if (selectionMode) { toggleSelect(pl.id, { shiftKey: e.shiftKey }); } else { navigate(`/playlists/${pl.id}`); } }} onContextMenu={(e) => { e.preventDefault(); if (selectionMode && selectedIds.size > 0) { openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist'); } else { openContextMenu(e.clientX, e.clientY, pl, 'playlist'); } }} onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }} style={selectionMode && selectedIds.has(pl.id) ? { position: 'relative', outline: '2px solid var(--accent)', outlineOffset: '2px', borderRadius: 'var(--radius-md)' } : { position: 'relative' }} > {!selectionMode && (
{isPlaylistDeletable(pl) && ( )} {isPlaylistDeletable(pl) && ( )}
)} {selectionMode && (
{selectedIds.has(pl.id) && }
)} {/* Cover area — server collage or fallback icon */}
{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 ? ( ) : (
)} {pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && (
)} {/* Play overlay — same pattern as AlbumCard */}
{isSmartPlaylistName(pl.name) && } {displayPlaylistName(pl.name)}
{t('playlists.songs', { n: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })} {(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && ( <> · {formatDuration(filteredDurationByPlaylist[pl.id] ?? pl.duration)} )}
))}
)}
); }