Merge remote-tracking branch 'origin/main' into exp/orbit

# Conflicts:
#	src/api/subsonic.ts
This commit is contained in:
Psychotoxical
2026-04-24 19:57:34 +02:00
15 changed files with 1401 additions and 40 deletions
+32 -7
View File
@@ -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 { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw } from 'lucide-react';
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';
@@ -56,6 +56,18 @@ function totalDurationLabel(songs: SubsonicSong[]): string {
return formatHumanHoursMinutes(total);
}
const SMART_PREFIX = 'psy-smart-';
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 codecLabel(song: SubsonicSong, showBitrate: boolean): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
@@ -226,6 +238,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,
@@ -403,6 +416,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));
@@ -410,13 +431,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);
});
@@ -1148,7 +1170,10 @@ export default function PlaylistDetail() {
<div className="album-detail-meta">
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="album-detail-title" style={{ marginBottom: 0, marginTop: 6 }}>{playlist.name}</h1>
<h1 className="album-detail-title" style={{ marginBottom: 0, marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
{isSmartPlaylistName(playlist.name) && <Sparkles size={16} style={{ color: 'var(--text-muted)' }} />}
<span>{displayPlaylistName(playlist.name)}</span>
</h1>
<button
className="btn btn-ghost"
onClick={() => setEditingMeta(true)}
+718 -12
View File
@@ -1,18 +1,170 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react';
import { getPlaylists, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } 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';
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';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
}
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<string, unknown>;
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<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
function parseSmartRulesToFilters(
rules: Record<string, unknown> | 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();
@@ -23,10 +175,24 @@ export default function Playlists() {
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, setSmartCoverIdsByPlaylist] = useState<Record<string, string[]>>({});
const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState<Record<string, number>>({});
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState<Record<string, number>>({});
const [playingId, setPlayingId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
@@ -34,6 +200,10 @@ export default function Playlists() {
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const isNavidromeServer = Boolean(
activeServerId &&
(subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome',
);
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
@@ -54,17 +224,109 @@ export default function Playlists() {
};
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<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]);
// 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<string, number> = {};
const nextDuration: Record<string, number> = {};
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);
@@ -74,13 +336,219 @@ export default function Playlists() {
setNewName('');
};
const buildSmartRulesPayload = (): Record<string, unknown> => {
const all: Record<string, unknown>[] = [];
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<string, unknown> = { 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<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();
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 tracks = data.songs.map(songToTrack);
const filteredSongs = await filterSongsToActiveLibrary(data.songs);
const tracks = filteredSongs.map(songToTrack);
if (tracks.length > 0) {
touchPlaylist(pl.id);
playTrack(tracks[0], tracks);
@@ -93,6 +561,10 @@ export default function Playlists() {
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 {
@@ -109,9 +581,10 @@ export default function Playlists() {
};
const handleDeleteSelected = async () => {
if (selectedPlaylists.length === 0) return;
const deletable = selectedPlaylists.filter(isPlaylistDeletable);
if (deletable.length === 0) return;
let deleted = 0;
for (const pl of selectedPlaylists) {
for (const pl of deletable) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
@@ -121,7 +594,7 @@ export default function Playlists() {
}
}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !selectedIds.has(p.id)),
playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))),
}));
clearSelection();
if (deleted > 0) {
@@ -169,6 +642,49 @@ export default function Playlists() {
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">
@@ -201,10 +717,21 @@ export default function Playlists() {
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<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>
)}
</>
)}
<button
@@ -219,6 +746,109 @@ export default function Playlists() {
</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 ? (
@@ -246,11 +876,41 @@ export default function Playlists() {
}}
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} />}
@@ -258,7 +918,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)}
@@ -270,6 +947,29 @@ export default function Playlists() {
<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">
@@ -288,16 +988,22 @@ export default function Playlists() {
</div>
<div className="album-card-info">
<div className="album-card-title">{pl.name}</div>
<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: pl.songCount })}
{pl.duration > 0 && <> · {formatDuration(pl.duration)}</>}
{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>
);
}