Files
psysonic/src/pages/Playlists.tsx
T
Frank Stellmacher 2380543d59 refactor(playlists): G.79 — extract smart helpers + cover images + 2 lazy-fetch hooks (cluster) (#646)
Four-cut cluster opening the Playlists refactor. 1039 → 763 LOC
(−276).

playlistsSmart — full smart-playlist module: SMART_PREFIX /
LIMIT_MAX / YEAR_MIN / YEAR_MAX constants, GenreMode / YearMode /
SmartFilters / PendingSmartPlaylist / NdSmartRuleNode types,
defaultSmartFilters seed, clampYear / isSmartPlaylistName /
displayPlaylistName / asRecord helpers, parseSmartRulesToFilters
(the Navidrome JSON rule walker), and buildSmartRulesPayload (the
reverse — page-state → Navidrome JSON). The payload builder
becomes parameterized on the filters object so it lives outside
the component.

PlaylistCoverImages — two tiny CachedImage wrappers
(PlaylistSmartCoverCell for the 200 px collage cells,
PlaylistCardMainCover for the 256 px main card cover).

useSmartCoverCollage — replaces the inline useEffect that builds
the 2×2 cover collage for each smart playlist (pulls playlist
tracks, filters to active library scope, collects up to four
unique cover-art ids). Returns the per-playlist id map and
re-fetches on playlist list change or library filter version bump.

usePlaylistsLibraryScopeCounts — replaces the inline useEffect
that recomputes per-playlist song count + total duration under
the current library scope. Chunked into batches of four parallel
fetches.

Playlists drops the inline definitions; pure code move otherwise.
2026-05-13 15:52:09 +02:00

764 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { deletePlaylist, getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
import { getGenres } from '../api/subsonicGenres';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
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 { 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';
import {
SMART_PREFIX, LIMIT_MAX, YEAR_MIN, YEAR_MAX,
defaultSmartFilters, parseSmartRulesToFilters, buildSmartRulesPayload,
isSmartPlaylistName, displayPlaylistName, clampYear,
type SmartFilters, type PendingSmartPlaylist,
} from '../utils/playlistsSmart';
import { PlaylistSmartCoverCell, PlaylistCardMainCover } from '../components/playlists/PlaylistCoverImages';
import { useSmartCoverCollage } from '../hooks/useSmartCoverCollage';
import { usePlaylistsLibraryScopeCounts } from '../hooks/usePlaylistsLibraryScopeCounts';
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 = async (pl: SubsonicPlaylist) => {
if (!isNavidromeServer || !isSmartPlaylistName(pl.name)) return;
setCreatingSmartBusy(true);
try {
let target: { id: string; name: string; rules?: Record<string, unknown> } | 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(smartFilters);
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 (
<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>
{/* ── Header row ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('playlists.selectionCount', { count: selectedIds.size })
: t('playlists.title')}
</h1>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => { setCreatingSmart(false); setCreating(true); }}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
{!creating && isNavidromeServer && (
<button className="btn btn-surface" onClick={() => {
setCreating(false);
setEditingSmartId(null);
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
setCreatingSmart(v => !v);
}}>
<Plus size={15} /> {t('smartPlaylists.create')}
</button>
)}
</>
)}
{selectionMode && selectedIds.size > 0 && (() => {
const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length;
return (
<button
className="btn btn-danger"
onClick={handleDeleteSelected}
disabled={deletableCount === 0}
data-tooltip={deletableCount === selectedIds.size
? undefined
: t('playlists.deleteSelectedPartial', { n: deletableCount, total: selectedIds.size })}
data-tooltip-pos="bottom"
>
<Trash2 size={15} />
{t('playlists.deleteSelected')}
</button>
);
})()}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
</button>
</div>
</div>
{creatingSmart && (
<div style={{ marginBottom: '1rem', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '0.9rem', background: 'var(--bg-card)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionBasic')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
<input className="input" placeholder={t('smartPlaylists.name')} value={smartFilters.name} onChange={e => setSmartFilters(v => ({ ...v, name: e.target.value }))} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<input className="input" type="number" min={1} max={LIMIT_MAX} placeholder={t('smartPlaylists.limit')} value={smartFilters.limit} onChange={e => setSmartFilters(v => ({ ...v, limit: e.target.value }))} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.limitHint', { max: LIMIT_MAX })}</span>
</div>
<select className="input" value={smartFilters.sort} onChange={e => setSmartFilters(v => ({ ...v, sort: e.target.value }))}>
<option value="+random">{t('smartPlaylists.sortRandom')}</option>
<option value="+title">{t('smartPlaylists.sortTitleAsc')}</option>
<option value="-title">{t('smartPlaylists.sortTitleDesc')}</option>
<option value="-year">{t('smartPlaylists.sortYearDesc')}</option>
<option value="+year">{t('smartPlaylists.sortYearAsc')}</option>
<option value="-playcount">{t('smartPlaylists.sortPlayCountDesc')}</option>
</select>
</div>
</section>
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionGenres')}</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.genreMode')}</span>
<button className={`btn ${smartFilters.genreMode === 'include' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'include' }))}>{t('smartPlaylists.genreModeInclude')}</button>
<button className={`btn ${smartFilters.genreMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'exclude' }))}>{t('smartPlaylists.genreModeExclude')}</button>
</div>
<input className="input" placeholder={t('smartPlaylists.genreSearchPlaceholder')} value={genreQuery} onChange={e => setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.5rem', minHeight: 120 }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.availableGenres')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{availableGenres.map(g => (
<button key={g} className="btn btn-surface" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSmartFilters(v => ({ ...v, selectedGenres: [...v.selectedGenres, g] }))}>{g}</button>
))}
</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.5rem', minHeight: 120 }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.selectedGenres')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{smartFilters.selectedGenres.map(g => (
<button key={g} className="btn btn-surface" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSmartFilters(v => ({ ...v, selectedGenres: v.selectedGenres.filter(x => x !== g) }))}>× {g}</button>
))}
</div>
</div>
</div>
</section>
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionYearsAndFilters')}</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.yearMode')}</span>
<button className={`btn ${smartFilters.yearMode === 'include' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'include' }))}>{t('smartPlaylists.yearModeInclude')}</button>
<button className={`btn ${smartFilters.yearMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'exclude' }))}>{t('smartPlaylists.yearModeExclude')}</button>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-muted)' }}>
<span>{t('smartPlaylists.fromYear')}: {smartFilters.yearFrom}</span>
<span>{t('smartPlaylists.toYear')}: {smartFilters.yearTo}</span>
</div>
<div className="dual-year-range">
<div className="dual-year-range__track" />
<div className="dual-year-range__selected" style={{ left: `${((smartFilters.yearFrom - YEAR_MIN) / (YEAR_MAX - YEAR_MIN)) * 100}%`, right: `${100 - ((smartFilters.yearTo - YEAR_MIN) / (YEAR_MAX - YEAR_MIN)) * 100}%` }} />
<input type="range" min={YEAR_MIN} max={YEAR_MAX} value={smartFilters.yearFrom} onChange={e => setSmartFilters(v => ({ ...v, yearFrom: Math.min(clampYear(Number(e.target.value)), v.yearTo) }))} />
<input type="range" min={YEAR_MIN} max={YEAR_MAX} value={smartFilters.yearTo} onChange={e => setSmartFilters(v => ({ ...v, yearTo: Math.max(clampYear(Number(e.target.value)), v.yearFrom) }))} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', marginTop: '0.75rem' }}>
<input className="input" placeholder={t('smartPlaylists.artistContains')} value={smartFilters.artistContains} onChange={e => setSmartFilters(v => ({ ...v, artistContains: e.target.value }))} />
<input className="input" placeholder={t('smartPlaylists.albumContains')} value={smartFilters.albumContains} onChange={e => setSmartFilters(v => ({ ...v, albumContains: e.target.value }))} />
<input className="input" placeholder={t('smartPlaylists.titleContains')} value={smartFilters.titleContains} onChange={e => setSmartFilters(v => ({ ...v, titleContains: e.target.value }))} />
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.minRating')}: {smartFilters.minRating}</div>
<StarRating value={smartFilters.minRating} onChange={rating => setSmartFilters(v => ({ ...v, minRating: rating }))} ariaLabel={t('smartPlaylists.minRatingAria')} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.minRatingHint')}</span>
</div>
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" checked={smartFilters.excludeUnrated} onChange={e => setSmartFilters(v => ({ ...v, excludeUnrated: e.target.checked }))} />
{t('smartPlaylists.excludeUnrated')}
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" checked={smartFilters.compilationOnly} onChange={e => setSmartFilters(v => ({ ...v, compilationOnly: e.target.checked }))} />
{t('smartPlaylists.compilationOnly')}
</label>
</div>
</section>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<button
className="btn btn-surface"
onClick={() => {
setCreatingSmart(false);
setEditingSmartId(null);
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
}}
>
{t('playlists.cancel')}
</button>
<button className="btn btn-primary" onClick={handleCreateSmart} disabled={creatingSmartBusy}>
<Plus size={15} /> {editingSmartId ? t('smartPlaylists.save') : t('smartPlaylists.create')}
</button>
</div>
</div>
</div>
)}
{/* ── Grid ── */}
{playlists.length === 0 ? (
<div className="empty-state">{t('playlists.empty')}</div>
) : (
<div className="album-grid-wrap">
{playlists.map((pl) => (
<div
key={pl.id}
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`}
onClick={(e) => {
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 && (
<div className="playlist-card-actions">
{isPlaylistDeletable(pl) && (
<button
className="playlist-card-action playlist-card-action--edit"
onClick={(e) => {
e.stopPropagation();
if (isSmartPlaylistName(pl.name)) {
void handleOpenSmartEditor(pl);
return;
}
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' : ''}`}>
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
{/* Cover area — server collage or fallback icon */}
<div className="album-card-cover">
{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 ? (
<PlaylistSmartCoverCell key={i} coverId={id} />
) : (
<div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
);
})}
</div>
) : pl.coverArt ? (
<PlaylistCardMainCover coverArt={pl.coverArt} alt={pl.name} />
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<ListMusic size={48} strokeWidth={1.2} />
</div>
)}
{pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && (
<div
style={{
position: 'absolute',
top: 8,
left: 8,
width: 24,
height: 24,
borderRadius: 999,
background: 'rgba(0,0,0,0.45)',
border: '1px solid rgba(255,255,255,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
zIndex: 8,
pointerEvents: 'none',
}}
data-tooltip={t('common.loading')}
>
<Clock3 size={13} />
</div>
)}
{/* Play overlay — same pattern as AlbumCard */}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={(e) => handlePlay(e, pl)}
disabled={playingId === pl.id}
>
{playingId === pl.id
? <span className="spinner" style={{ width: 14, height: 14 }} />
: <Play size={15} fill="currentColor" />
}
</button>
</div>
</div>
<div className="album-card-info">
<div className="album-card-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isSmartPlaylistName(pl.name) && <Sparkles size={14} style={{ color: 'var(--text-muted)', flex: '0 0 auto' }} />}
<span>{displayPlaylistName(pl.name)}</span>
</div>
<div className="album-card-artist">
{t('playlists.songs', { n: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })}
{(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && (
<> · {formatDuration(filteredDurationByPlaylist[pl.id] ?? pl.duration)}</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}