Files
Psychotoxical-psysonic/src/pages/PlaylistDetail.tsx
T

2015 lines
86 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 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 } 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,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { usePlaylistStore } from '../store/playlistStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readTextFile } from '@tauri-apps/plugin-fs';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import StarRating from '../components/StarRating';
import Papa from 'papaparse';
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function totalDurationLabel(songs: SubsonicSong[]): string {
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
return formatHumanHoursMinutes(total);
}
function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
}
// ── CSV Import helpers ──────────────────────────────────────────────────────
interface SpotifyCsvTrack {
trackName: string;
artistName: string;
artistNames: string[]; // Array de todos los artistas para mejor matching
albumName: string;
isrc?: string;
}
// Mapeo de headers a campos canónicos (soporta inglés y español)
const HEADER_MAPPINGS: Record<string, string> = {
// Track name
'track name': 'trackName',
'track name(s)': 'trackName',
'track': 'trackName',
'name': 'trackName',
'nombre de la cancion': 'trackName',
'nombre de cancion': 'trackName',
'nombre de la canci\u00f3n': 'trackName',
'nombre cancion': 'trackName',
't\u00edtulo': 'trackName',
'titulo': 'trackName',
// Artist name
'artist name': 'artistName',
'artist name(s)': 'artistName',
'artists name': 'artistName',
'artists name(s)': 'artistName',
'artist': 'artistName',
'artists': 'artistName',
'nombre del artista': 'artistName',
'nombres del artista': 'artistName',
'nombre artista': 'artistName',
'artista': 'artistName',
// Album name
'album name': 'albumName',
'album name(s)': 'albumName',
'album': 'albumName',
'nombre del album': 'albumName',
'nombre del \u00e1lbum': 'albumName',
'nombre album': 'albumName',
// ISRC
'isrc': 'isrc',
'isrc code': 'isrc',
'codigo isrc': 'isrc',
'c\u00f3digo isrc': 'isrc',
};
function normalizeHeader(header: string): string {
return header
.toLowerCase()
.replace(/\(s\)/g, '')
.replace(/[()]/g, '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
}
function findColumnField(header: string): string | undefined {
const normalized = normalizeHeader(header);
return HEADER_MAPPINGS[normalized];
}
function parseArtists(artistField: string): string[] {
// Spotify usa comas en formato extendido, punto y coma en formato simple
const separator = artistField.includes(';') ? ';' : ',';
return artistField
.split(separator)
.map(a => a.trim())
.filter(a => a.length > 0);
}
function extractFeaturedArtists(title: string): string[] {
const patterns = [
/\(feat\.?\s+([^)]+)\)/i,
/\(ft\.?\s+([^)]+)\)/i,
/\(featuring\s+([^)]+)\)/i,
/\(with\s+([^)]+)\)/i,
];
for (const regex of patterns) {
const match = title.match(regex);
if (match) {
return match[1].split(/,|\sand\s|\s&\s/).map(s => s.trim()).filter(Boolean);
}
}
return [];
}
function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
// Strip BOM y parsear con Papa Parse
const cleanContent = csvContent.replace(/^\uFEFF/, '');
const parseResult = Papa.parse(cleanContent, {
header: true,
skipEmptyLines: true,
transformHeader: (header: string) => {
const field = findColumnField(header);
return field || header;
},
});
if (parseResult.errors.length > 0) {
console.warn('CSV parse warnings:', parseResult.errors);
}
const data = parseResult.data as Record<string, string>[];
// Verificar columnas requeridas
if (!data.length || !data[0].trackName || !data[0].artistName) {
console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {}));
return [];
}
console.log('CSV parsed with Papa Parse:', {
rows: data.length,
sample: data[0],
});
const tracks: SpotifyCsvTrack[] = [];
for (const row of data) {
const trackName = row.trackName?.trim();
const artistField = row.artistName?.trim() || '';
if (!trackName || !artistField) continue;
// Parsear múltiples artistas del campo + extraer colaboradores del título
const artistNames = parseArtists(artistField);
const featuredArtists = extractFeaturedArtists(trackName);
const allArtists = [...new Set([...artistNames, ...featuredArtists])];
const primaryArtist = allArtists[0] || '';
tracks.push({
trackName,
artistName: primaryArtist,
artistNames: allArtists,
albumName: row.albumName?.trim() || '',
isrc: row.isrc?.trim() || undefined,
});
}
return tracks;
}
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
useShallow(s => ({
playTrack: s.playTrack,
enqueue: s.enqueue,
openContextMenu: s.openContextMenu,
currentTrack: s.currentTrack,
isPlaying: s.isPlaying,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
userRatingOverrides: s.userRatingOverrides,
}))
);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const { startDrag, isDragging } = useDragDrop();
const downloadPlaylist = useOfflineStore(s => s.downloadPlaylist);
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const isDownloading = useOfflineJobStore(s =>
!!id && s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading'))
);
const isCached = useOfflineStore(s => {
if (!id) return false;
const meta = s.albums[`${activeServerId}:${id}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${activeServerId}:${tid}`]);
});
const offlineProgressDone = useOfflineJobStore(s => {
if (!id) return 0;
return s.jobs.filter(j => j.albumId === id && (j.status === 'done' || j.status === 'error')).length;
});
const offlineProgressTotal = useOfflineJobStore(s => (!id ? 0 : s.jobs.filter(j => j.albumId === id).length));
const offlineProgress = offlineProgressTotal > 0 ? { done: offlineProgressDone, total: offlineProgressTotal } : null;
const downloadFolder = useAuthStore(s => s.downloadFolder);
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
const showBitrate = useThemeStore(s => s.showBitrate);
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [ratings, setRatings] = useState<Record<string, number>>({});
const [editingMeta, setEditingMeta] = useState(false);
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
const [filterText, setFilterText] = useState('');
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [sortClickCount, setSortClickCount] = useState(0);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const zipDownloads = useZipDownloadStore(s => s.downloads);
const [zipDownloadId, setZipDownloadId] = useState<string | null>(null);
const activeZip = zipDownloadId ? zipDownloads.find(d => d.id === zipDownloadId) : undefined;
// ── CSV Import ───────────────────────────────────────────────────
const [csvImporting, setCsvImporting] = useState(false);
const [csvImportReport, setCsvImportReport] = useState<{
added: number;
notFound: SpotifyCsvTrack[];
duplicates: number;
duplicateTracks: SpotifyCsvTrack[];
total: number;
searchErrors?: SpotifyCsvTrack[];
} | null>(null);
// ── Bulk select ───────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showBulkPlPicker, setShowBulkPlPicker] = useState(false);
const toggleSelect = (id: string, idx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdx !== null) {
const from = Math.min(lastSelectedIdx, idx);
const to = Math.max(lastSelectedIdx, idx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
next.has(id) ? next.delete(id) : next.add(id);
}
return next;
});
setLastSelectedIdx(idx);
};
const allSelected = selectedIds.size === songs.length && songs.length > 0;
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
const bulkRemove = () => {
const prevCount = songs.length;
const next = songs.filter(s => !selectedIds.has(s.id));
setSongs(next);
savePlaylist(next, prevCount);
setSelectedIds(new Set());
};
useEffect(() => {
if (!showBulkPlPicker) return;
const handler = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowBulkPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showBulkPlPicker]);
// ── 2×2 cover quad (first 4 unique album covers) ─────────────
const coverQuad = useMemo(() => {
const seen = new Set<string>();
const result: string[] = [];
for (const s of songs) {
if (s.coverArt && !seen.has(s.coverArt)) {
seen.add(s.coverArt);
result.push(s.coverArt);
if (result.length === 4) break;
}
}
return result;
}, [songs]);
// Stable fetch URLs + cache keys for the 2×2 grid and blurred background.
// buildCoverArtUrl generates a new crypto salt on every call, so these MUST
// be memoized — otherwise every render produces new URLs, useCachedUrl
// re-triggers, state updates, another render → infinite flicker loop.
const coverQuadUrls = useMemo(() =>
Array.from({ length: 4 }, (_, i) => {
const coverId = coverQuad[i % Math.max(1, coverQuad.length)];
if (!coverId) return null;
return { src: buildCoverArtUrl(coverId, 200), cacheKey: coverArtCacheKey(coverId, 200) };
}),
[coverQuad]);
const effectiveBgId = customCoverId ?? coverQuad[0] ?? '';
const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]);
const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]);
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
const customCoverFetchUrl = useMemo(
() => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null,
[customCoverId],
);
const customCoverCacheKey = useMemo(
() => customCoverId ? coverArtCacheKey(customCoverId, 300) : null,
[customCoverId],
);
// Song search
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SubsonicSong[]>([]);
const [searching, setSearching] = useState(false);
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
const [selectedSearchIds, setSelectedSearchIds] = useState<Set<string>>(new Set());
const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false);
// Suggestions
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
// ── Column resize/visibility ──────────────────────────────────────────────
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns');
// DnD
const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null);
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// ── Load ─────────────────────────────────────────────────────
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
useEffect(() => {
if (!id) return;
setLoading(true);
getPlaylist(id)
.then(({ playlist, songs }) => {
setPlaylist(playlist);
setSongs(songs);
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
const init: Record<string, number> = {};
const starred = new Set<string>();
songs.forEach(s => {
if (s.userRating) init[s.id] = s.userRating;
if (s.starred) starred.add(s.id);
});
setRatings(init);
setStarredSongs(starred);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [id, lastModified]);
// ── Suggestions ───────────────────────────────────────────────
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
if (!currentSongs.length) return;
// Count genres across playlist songs, pick the most common one
const genreCounts: Record<string, number> = {};
for (const s of currentSongs) {
if (s.genre) genreCounts[s.genre] = (genreCounts[s.genre] ?? 0) + 1;
}
const genres = Object.entries(genreCounts).sort((a, b) => b[1] - a[1]);
// Fall back to no genre filter if none of the songs have genre tags
const genre = genres.length > 0 ? genres[Math.floor(Math.random() * Math.min(3, genres.length))][0] : undefined;
const existingIds = new Set(currentSongs.map(s => s.id));
setLoadingSuggestions(true);
setSuggestions([]);
try {
const random = await getRandomSongs(25, genre);
setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10));
} catch {}
setLoadingSuggestions(false);
}, []);
useEffect(() => {
if (songs.length > 0) loadSuggestions(songs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlist?.id]);
// ── Save ──────────────────────────────────────────────────────
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => {
if (!id) return;
setSaving(true);
try {
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
if (id) touchPlaylist(id);
} catch {}
setSaving(false);
}, [id, touchPlaylist]);
// ── Meta edit ─────────────────────────────────────────────────
const handleSaveMeta = async (opts: {
name: string; comment: string; isPublic: boolean;
coverFile: File | null; coverRemoved: boolean;
}) => {
if (!id || !playlist) return;
await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic);
setPlaylist(p => p
? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic }
: p
);
if (opts.coverFile) {
try {
await uploadPlaylistCoverArt(id, opts.coverFile);
const { playlist: refreshed } = await getPlaylist(id);
setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev);
if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt);
showToast(t('playlists.coverUpdated'));
} catch (err) {
showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error');
}
} else if (opts.coverRemoved) {
setCustomCoverId(null);
}
showToast(t('playlists.metaSaved'));
setEditingMeta(false);
};
// ── ZIP Download ──────────────────────────────────────────────
const handleDownload = async () => {
if (!playlist || !id) return;
const folder = downloadFolder || await requestDownloadFolder();
if (!folder) return;
const filename = `${sanitizeFilename(playlist.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(id);
const downloadId = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
start(downloadId, filename);
setZipDownloadId(downloadId);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
console.error('ZIP download failed:', e);
}
};
// ── CSV Import ──────────────────────────────────────────────
// Normalizar strings para matching: quita tildes, caracteres especiales, lowercase, trim
const normalizeForMatching = (s: string): string =>
s.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[ø]/gi, 'o')
.replace(/[æ]/gi, 'ae')
.trim();
// Limpia sufijos comunes de títulos (remasterizados, en vivo, ediciones, etc.)
const cleanTrackTitle = (title: string): string => {
const suffixes = [
// Remastered variants
/\s*-\s*remasterizado$/i,
/\s*-\s*remaster$/i,
/\s*-\s*remastered$/i,
/\s*\(remasterizado\)$/i,
/\s*\(remaster\)$/i,
/\s*\(remastered\)$/i,
/\s*\[remasterizado\]$/i,
/\s*\[remaster\]$/i,
/\s*\[remastered\]$/i,
/\s*-\s*remasterizado\s+\d{4}$/i,
/\s*-\s*remastered\s+\d{4}$/i,
/\s*\(\d{4}\s+remaster\)$/i,
/\s*\(\d{4}\s+remastered\)$/i,
// Live variants
/\s*-\s*en vivo$/i,
/\s*-\s*live$/i,
/\s*-\s*version en vivo$/i,
/\s*-\s*studio version$/i,
/\s*-\s*version de estudio$/i,
/\s*\(en vivo\)$/i,
/\s*\(live\)$/i,
/\s*\(live .*\)$/i,
/\s*\[en vivo\]$/i,
/\s*\[live\]$/i,
/\s*\[live .*\]$/i,
/\s*-\s*live at.*$/i,
/\s*\(live at.*\)$/i,
// Version/Edition variants
/\s*-\s*version$/i,
/\s*-\s*versión$/i,
/\s*\(version\)$/i,
/\s*\(versión\)$/i,
/\s*\[version\]$/i,
/\s*\[versión\]$/i,
/\s*-\s*album version$/i,
/\s*\(album version\)$/i,
/\s*\[album version\]$/i,
// Radio/Edit variants
/\s*-\s*radio edit$/i,
/\s*-\s*radio version$/i,
/\s*\(radio edit\)$/i,
/\s*\(radio version\)$/i,
/\s*\[radio edit\]$/i,
/\s*\[radio version\]$/i,
/\s*-\s*edit$/i,
/\s*\(edit\)$/i,
/\s*\[edit\]$/i,
// Acoustic/Instrumental variants
/\s*-\s*acoustic$/i,
/\s*-\s*acústico$/i,
/\s*\(acoustic\)$/i,
/\s*\(acústico\)$/i,
/\s*\[acoustic\]$/i,
/\s*\[acústico\]$/i,
/\s*-\s*instrumental$/i,
/\s*\(instrumental\)$/i,
/\s*\[instrumental\]$/i,
// Featuring/Feat/Ft/With variants
/\s*\(feat\.?\s+.*\)$/i,
/\s*\[feat\.?\s+.*\]$/i,
/\s*-\s*feat\.?\s+.*$/i,
/\s*\(featuring\s+.*\)$/i,
/\s*\[featuring\s+.*\]$/i,
/\s*\(ft\.?\s+.*\)$/i,
/\s*\[ft\.?\s+.*\]$/i,
/\s*-\s*ft\.?\s+.*$/i,
/\s*\(with\s+.*\)$/i,
/\s*\[with\s+.*\]$/i,
/\s*-\s*with\s+.*$/i,
/\s*ft\.?\s+.*$/i,
// Explicit/Clean tags
/\s*\(explicit\)$/i,
/\s*\[explicit\]$/i,
/\s*\(clean\)$/i,
/\s*\[clean\]$/i,
// Mono/Stereo
/\s*\(mono\)$/i,
/\s*\[mono\]$/i,
/\s*\(stereo\)$/i,
/\s*\[stereo\]$/i,
// Deluxe/Special editions
/\s*-\s*deluxe$/i,
/\s*\(deluxe\)$/i,
/\s*\[deluxe\]$/i,
/\s*-\s*special edition$/i,
/\s*\(special edition\)$/i,
/\s*\[special edition\]$/i,
// Year in parentheses (common in remasters)
/\s*\(\d{4}\)$/i,
];
let cleaned = title.trim();
// Aplicar patrones múltiples veces para casos anidados
for (let i = 0; i < 3; i++) {
const previous = cleaned;
for (const regex of suffixes) {
cleaned = cleaned.replace(regex, '');
}
cleaned = cleaned.trim();
if (previous === cleaned) break; // No más cambios
}
return cleaned;
};
// Distancia de Levenshtein para scoring de similitud
const levenshtein = (a: string, b: string): number => {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
matrix[i][j] = b[i - 1] === a[j - 1]
? matrix[i - 1][j - 1]
: Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
return matrix[b.length][a.length];
};
const similarityScore = (a: string, b: string): number => {
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 1;
// Usar strings normalizados (sin tildes) para comparación
const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b));
return 1 - dist / maxLen;
};
// Procesa búsquedas en batches para no saturar el servidor
const processBatch = async <T, R>(
items: T[],
batchSize: number,
processor: (item: T) => Promise<R | null>
): Promise<(R | null)[]> => {
const results: (R | null)[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
}
return results;
};
const handleImportCsv = async () => {
if (!id || csvImporting) return;
try {
const selected = await openDialog({
filters: [{ name: 'CSV Files', extensions: ['csv'] }],
multiple: false,
title: 'Import Spotify Playlist CSV',
});
if (!selected || typeof selected !== 'string') return;
setCsvImporting(true);
const content = await readTextFile(selected);
const csvTracks = parseSpotifyCsv(content);
if (csvTracks.length === 0) {
showToast('No valid tracks found in CSV file', 3000, 'error');
setCsvImporting(false);
return;
}
const existingIds = new Set(songs.map(s => s.id));
const addedSongs: SubsonicSong[] = [];
const notFound: SpotifyCsvTrack[] = [];
const searchErrors: SpotifyCsvTrack[] = [];
const duplicateTracks: SpotifyCsvTrack[] = [];
let duplicateCount = 0;
// Procesar en batches de 10 para balance velocidad/servidor
await processBatch(csvTracks, 10, async (track) => {
try {
// Retry: 2 intentos en caso de error de red
let searchResult;
let attempts = 0;
const maxAttempts = 2;
while (attempts < maxAttempts) {
try {
searchResult = await search(track.trackName, { songCount: 40, artistCount: 0, albumCount: 0 });
break;
} catch (err) {
attempts++;
if (attempts >= maxAttempts) throw err;
// Esperar 500ms antes de reintentar
await new Promise(r => setTimeout(r, 500));
}
}
if (!searchResult || searchResult.songs.length === 0) {
notFound.push(track);
return null;
}
// Scoring de confianza para cada resultado
// Limpiar título del CSV para comparación justa
const cleanCsvTitle = cleanTrackTitle(track.trackName);
const scoredMatches = searchResult.songs.map(s => {
// Vía rápida ISRC: si ambos tienen ISRC y coinciden, score perfecto
if (track.isrc && s.isrc && track.isrc.toUpperCase() === s.isrc.toUpperCase()) {
return { song: s, score: 1.0, titleScore: 1.0, artistScore: 1.0, isrcMatch: true };
}
// Limpiar título del resultado también
const cleanResultTitle = cleanTrackTitle(s.title);
const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle);
// Scoring de artista: máximo score contra cualquiera de los artistas del CSV
const artistScore = s.artist
? Math.max(...track.artistNames.map(csvArtist =>
similarityScore(s.artist || '', csvArtist)
))
: 0;
// Si no hay álbum en CSV o en local, usar 1.0 (neutral) para no penalizar
const albumScore = (s.album && track.albumName)
? similarityScore(s.album, track.albumName)
: 1.0;
// Peso dinámico: títulos específicos (>4 palabras) → más peso al título
const titleWords = cleanCsvTitle.split(/\s+/).length;
const isSpecificTitle = titleWords > 4;
const titleWeight = isSpecificTitle ? 0.55 : 0.4;
const artistWeight = isSpecificTitle ? 0.25 : 0.4;
const totalScore = artistScore * artistWeight + titleScore * titleWeight + albumScore * 0.2;
return { song: s, score: totalScore, titleScore, artistScore, albumScore, isrcMatch: false };
}).sort((a, b) => b.score - a.score);
// Solo aceptar si el score es >= 0.7 (70% de confianza)
const bestMatch = scoredMatches[0];
if (bestMatch.score < 0.7) {
notFound.push({ ...track, albumName: `${track.albumName} (score: ${bestMatch.score.toFixed(2)})` });
return null;
}
// Check duplicados
if (existingIds.has(bestMatch.song.id)) {
duplicateCount++;
duplicateTracks.push(track);
return null;
}
// Check duplicados en los que ya vamos a agregar
if (addedSongs.some(s => s.id === bestMatch.song.id)) {
duplicateCount++;
duplicateTracks.push(track);
return null;
}
addedSongs.push(bestMatch.song);
existingIds.add(bestMatch.song.id);
return bestMatch.song;
} catch {
// Error de red/timeout - separar de "no encontrado"
searchErrors.push(track);
return null;
}
});
if (addedSongs.length > 0) {
const next = [...songs, ...addedSongs];
setSongs(next);
await savePlaylist(next);
}
// Auto-show report if there are not found tracks, duplicates, or search errors
if (notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0) {
// Small delay to let the toast appear first
setTimeout(() => {
setCsvImportReport({
added: addedSongs.length,
notFound,
duplicates: duplicateCount,
duplicateTracks,
total: csvTracks.length,
searchErrors,
});
}, 500);
}
const errorMsg = searchErrors.length > 0
? ` (${searchErrors.length} network errors - may retry)`
: '';
// Determinar tipo de toast según resultados:
// - success: todas las canciones fueron añadidas correctamente
// - warning: al menos una añadida, pero algunas no se encontraron/duplicadas
// - error: ninguna añadida (todas duplicadas o no encontradas)
const hasAdded = addedSongs.length > 0;
const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0;
let toastVariant: 'success' | 'warning' | 'error';
if (hasAdded && !hasIssues) {
toastVariant = 'success';
} else if (hasAdded && hasIssues) {
toastVariant = 'warning';
} else {
toastVariant = 'error';
}
showToast(
`${addedSongs.length} added, ${notFound.length} not found, ${duplicateCount} duplicates${errorMsg}`,
5000,
toastVariant
);
} catch (err) {
console.error('CSV import failed:', err);
showToast('Failed to import CSV file', 3000, 'error');
} finally {
setCsvImporting(false);
}
};
// ── Remove ────────────────────────────────────────────────────
const removeSong = (idx: number) => {
const prevCount = songs.length;
const next = songs.filter((_, i) => i !== idx);
setSongs(next);
savePlaylist(next, prevCount);
};
// ── Add ───────────────────────────────────────────────────────
const addSong = (song: SubsonicSong) => {
if (songs.some(s => s.id === song.id)) return;
const next = [...songs, song];
setSongs(next);
savePlaylist(next);
setSuggestions(prev => prev.filter(s => s.id !== song.id));
setSearchResults(prev => prev.filter(s => s.id !== song.id));
};
// ── Rating / Star ─────────────────────────────────────────────
const handleRate = (songId: string, rating: number) => {
setRatings(prev => ({ ...prev, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
setRating(songId, rating).catch(() => {});
};
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
setStarredSongs(prev => {
const next = new Set(prev);
isStarred ? next.delete(song.id) : next.add(song.id);
return next;
});
setStarredOverride(song.id, !isStarred);
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
};
// ── Search ────────────────────────────────────────────────────
useEffect(() => {
if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; }
if (searchDebounce.current) clearTimeout(searchDebounce.current);
searchDebounce.current = setTimeout(async () => {
setSearching(true);
try {
const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 });
const existingIds = new Set(songs.map(s => s.id));
setSearchResults(res.songs.filter(s => !existingIds.has(s.id)));
} catch {}
setSearching(false);
}, 350);
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
}, [searchQuery, searchOpen, songs]);
// ── psy-drop DnD reordering ───────────────────────────────────
useEffect(() => {
const container = tracklistRef.current;
if (!container) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: any;
try { parsed = JSON.parse(detail.data); } catch { return; }
if (parsed.type !== 'playlist_reorder') return;
setDropTargetIdx(null);
const fromIdx: number = parsed.index;
// Determine drop index from the event target row
const target = (e.target as HTMLElement).closest('[data-track-idx]');
let toIdx = songs.length;
if (target) {
const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10);
const rect = target.getBoundingClientRect();
const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2);
const before = cursorY < rect.top + rect.height / 2;
toIdx = before ? targetIdx : targetIdx + 1;
}
if (fromIdx === toIdx || fromIdx === toIdx - 1) return;
setSongs(prev => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx;
next.splice(insertAt, 0, moved);
savePlaylist(next);
return next;
});
};
container.addEventListener('psy-drop', onPsyDrop);
return () => container.removeEventListener('psy-drop', onPsyDrop);
}, [songs, savePlaylist]);
// ── Row mousedown: threshold drag for reorder (from anywhere on the row) ──
const handleRowMouseDown = (e: React.MouseEvent, idx: number) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('button, input')) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (!isFiltered && selectedIds.has(songs[idx]?.id) && selectedIds.size > 1) {
const bulkTracks = songs.filter(s => selectedIds.has(s.id)).map(songToTrack);
startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY);
} else if (!isFiltered) {
startDrag(
{ data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' },
me.clientX, me.clientY
);
} else {
// filtered view: single-song drag to queue
startDrag(
{ data: JSON.stringify({ type: 'song', track: songToTrack(songs[idx]) }), label: songs[idx]?.title ?? '' },
me.clientX, me.clientY
);
}
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
// ── Memoized derivations ──────────────────────────────────────
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
const tracks = useMemo(() => songs.map(songToTrack), [songs]);
const displayedSongs = useMemo(() => {
const q = filterText.trim().toLowerCase();
if (!q && sortKey === 'natural') return songs;
let result = [...songs];
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
if (sortKey !== 'natural') {
result.sort((a, b) => {
let av: string | number;
let bv: string | number;
const effectiveRating = (s: SubsonicSong) => ratings[s.id] ?? userRatingOverrides[s.id] ?? s.userRating ?? 0;
const effectiveStarred = (s: SubsonicSong) => (s.id in starredOverrides ? starredOverrides[s.id] : starredSongs.has(s.id)) ? 1 : 0;
switch (sortKey) {
case 'title': av = a.title; bv = b.title; break;
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
case 'favorite': av = effectiveStarred(a); bv = effectiveStarred(b); break;
case 'rating': av = effectiveRating(a); bv = effectiveRating(b); break;
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
default: av = a.title; bv = b.title;
}
if (typeof av === 'number' && typeof bv === 'number') {
return sortDir === 'asc' ? av - bv : bv - av;
}
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
});
}
return result;
}, [songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs]);
const displayedTracks = useMemo(
() => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack),
[displayedSongs, songs, tracks],
);
const isFiltered = displayedSongs !== songs;
// ── Drag-over visual feedback ─────────────────────────────────
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
if (!isDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
setDropTargetIdx({ idx, before });
};
// ── Render ────────────────────────────────────────────────────
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
);
}
if (!playlist) {
return <div className="content-body"><div className="empty-state">{t('playlists.notFound')}</div></div>;
}
return (
<div className="content-body animate-fade-in">
{/* ── Hero ── */}
<div className="album-detail-header">
{resolvedBgUrl && enableCoverArtBackground && (
<>
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
<div className="album-detail-overlay" aria-hidden="true" />
</>
)}
<div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={() => navigate('/playlists')}>
<ChevronLeft size={16} /> {t('playlists.title')}
</button>
<div className="album-detail-hero">
{/* Cover — click to open edit modal */}
{enablePlaylistCoverPhoto && (
<div
className="playlist-hero-cover"
onClick={() => setEditingMeta(true)}
>
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
className="playlist-cover-grid"
style={{ objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid">
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-hero-cover-overlay">
<Camera size={28} />
</div>
</div>
)}
<div className="album-detail-meta">
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="album-detail-title" style={{ marginBottom: 0 }}>{playlist.name}</h1>
<button
className="btn btn-ghost"
onClick={() => setEditingMeta(true)}
data-tooltip={t('playlists.editMeta')}
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
>
<Pencil size={14} />
</button>
</div>
{playlist.comment && (
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
)}
</>
<div className="album-detail-info">
<span>{t('playlists.songs', { n: songs.length })}</span>
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
{playlist.public !== undefined && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
· {playlist.public
? <><Globe size={11} /> {t('playlists.publicLabel')}</>
: <><Lock size={11} /> {t('playlists.privateLabel')}</>}
</span>
)}
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
</div>
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
playTrack(tracks[0], tracks);
}}>
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
</button>
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
const shuffled = [...tracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
playTrack(shuffled[0], shuffled);
}}>
<Shuffle size={16} /> {t('playlists.shuffle', 'Shuffle')}
</button>
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
enqueue(tracks);
}}>
<ListPlus size={16} /> {t('playlists.addToQueue')}
</button>
</div>
<button
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
>
<Search size={16} /> {t('playlists.addSongs')}
</button>
<button
className="btn btn-ghost"
onClick={handleImportCsv}
disabled={csvImporting}
data-tooltip={t('playlists.importCSVTooltip')}
>
{csvImporting ? <Loader2 size={16} className="spin-slow" /> : <FileUp size={16} />}
{t('playlists.importCSV')}
</button>
{/* search close resets selection */}
{songs.length > 0 && id && (
<button
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
disabled={isDownloading}
onClick={() => {
if (isCached) {
deleteAlbum(id, activeServerId);
} else if (playlist) {
downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId);
}
}}
data-tooltip={isDownloading
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
>
{isDownloading
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
: isCached ? <Trash2 size={16} /> : <HardDriveDownload size={16} />}
</button>
)}
{songs.length > 0 && (
activeZip && !activeZip.done && !activeZip.error ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : 0}%` }} />
</div>
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
</div>
) : (
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
<Download size={16} /> {t('playlists.downloadZip')}
</button>
)
)}
</div>
</div>
</div>
</div>
</div>
{/* ── Song search panel ── */}
{searchOpen && (
<div className="playlist-search-panel">
<div className="playlist-search-input-wrap">
<input
className="input"
placeholder={t('playlists.searchPlaceholder')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
autoFocus
/>
{searchQuery && (
<button className="live-search-clear" onClick={() => { setSearchQuery(''); setSearchResults([]); }}>
<X size={14} />
</button>
)}
</div>
{searching && <div style={{ textAlign: 'center', padding: '0.75rem' }}><div className="spinner" /></div>}
{!searching && searchQuery && searchResults.length === 0 && (
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
)}
{selectedSearchIds.size > 0 && (
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.4rem 0.75rem', background: 'color-mix(in srgb, var(--accent) 10%, transparent)', borderRadius: 'var(--radius-sm)', margin: '0.25rem 0' }}>
<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, flex: 1 }}>
{t('common.bulkSelected', { count: selectedSearchIds.size })}
</span>
<button
className="btn btn-sm btn-ghost"
style={{ fontSize: 12 }}
onClick={() => setSelectedSearchIds(new Set())}
>
{t('common.clearSelection')}
</button>
<div style={{ position: 'relative' }}>
<button
className="btn btn-sm btn-primary"
style={{ fontSize: 12 }}
onClick={() => setSearchPlPickerOpen(v => !v)}
>
<ListPlus size={13} /> {t('contextMenu.addToPlaylist')}
</button>
{searchPlPickerOpen && (
<AddToPlaylistSubmenu
songIds={[...selectedSearchIds]}
dropDown
onDone={() => { setSearchPlPickerOpen(false); setSelectedSearchIds(new Set()); }}
/>
)}
</div>
<button
className="btn btn-sm btn-primary"
style={{ fontSize: 12 }}
onClick={() => {
searchResults
.filter(s => selectedSearchIds.has(s.id))
.forEach(s => addSong(s));
setSelectedSearchIds(new Set());
}}
>
<Check size={13} /> {t('playlists.addSelected')}
</button>
</div>
)}
{searchResults.map(song => {
const isSelected = selectedSearchIds.has(song.id);
return (
<div
key={song.id}
className={`playlist-search-row${isSelected ? ' playlist-search-row--selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => addSong(song)}
>
<input
type="checkbox"
className="playlist-search-checkbox"
checked={isSelected}
onClick={e => e.stopPropagation()}
onChange={() => setSelectedSearchIds(prev => {
const next = new Set(prev);
next.has(song.id) ? next.delete(song.id) : next.add(song.id);
return next;
})}
/>
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
<div className="playlist-search-info">
<span className="playlist-search-title">{song.title}</span>
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
</div>
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
</div>
);
})}
</div>
)}
{/* ── Filter / sort toolbar ── */}
{songs.length > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
<input
className="input"
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
placeholder={t('albumDetail.filterSongs')}
value={filterText}
onChange={e => setFilterText(e.target.value)}
/>
{filterText && (
<button
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, lineHeight: 1 }}
onClick={() => setFilterText('')}
>×</button>
)}
</div>
</div>
)}
{/* ── Tracklist ── */}
<div className="tracklist" ref={tracklistRef}>
{/* Bulk action bar */}
{selectedIds.size > 0 && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedIds.size })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowBulkPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showBulkPlPicker && (
<AddToPlaylistSubmenu
songIds={[...selectedIds]}
onDone={() => { setShowBulkPlPicker(false); setSelectedIds(new Set()); }}
dropDown
/>
)}
</div>
<button
className="btn btn-surface btn-sm"
style={{ color: 'var(--danger)' }}
onClick={bulkRemove}
>
<Trash2 size={14} />
{t('common.bulkRemoveFromPlaylist')}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setSelectedIds(new Set())}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* Header */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header tracklist-va" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
const sortableCols = new Set(['title', 'artist', 'favorite', 'rating', 'duration', 'album']);
const canSort = sortableCols.has(key);
const isSortActive = canSort && sortKey === key;
const handleSortClick = () => {
if (!canSort) return;
if (sortKey === key) {
const nextCount = sortClickCount + 1;
if (nextCount >= 3) {
setSortKey('natural');
setSortDir('asc');
setSortClickCount(0);
} else {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
setSortClickCount(nextCount);
}
} else {
setSortKey(key as typeof sortKey);
setSortDir('asc');
setSortClickCount(1);
}
};
const renderSortIndicator = () => {
if (!isSortActive) return null;
return (
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
{sortDir === 'asc' ? '▲' : '▼'}
</span>
);
};
if (key === 'num') return (
<div key="num" className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div
key="title"
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'delete') return <div key="delete" />;
return (
<div
key={key}
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
})}
</div>
<div className="tracklist-col-picker" ref={pickerRef}>
<button
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{PL_COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
{label}
</button>
);
})}
</div>
)}
</div>
</div>
{songs.length === 0 && (
<div className="empty-state" style={{ padding: '2rem 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<span>{t('playlists.emptyPlaylist')}</span>
<button className="btn btn-primary" onClick={() => setSearchOpen(true)}>
<Search size={15} />
{t('playlists.addFirstSong')}
</button>
</div>
)}
{displayedSongs.map((song, i) => {
const realIdx = isFiltered ? songs.indexOf(song) : i;
return (
<React.Fragment key={song.id + i}>
{!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
<div
data-track-idx={realIdx}
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle}
onMouseEnter={e => !isFiltered && handleRowMouseEnter(i, e)}
onMouseDown={e => handleRowMouseDown(e, realIdx)}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey) {
toggleSelect(song.id, i, false);
} else if (selectedIds.size > 0) {
toggleSelect(song.id, i, e.shiftKey);
} else {
playTrack(displayedTracks[i], displayedTracks);
}
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, id, realIdx);
}}
>
{visibleCols.map(colDef => {
const inSelectMode = selectedIds.size > 0;
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(displayedTracks[i], displayedTracks); }}>
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{i + 1}</span>
</div>
);
case 'title': return (
<div key="title" className="track-info"><span className="track-title">{song.title}</span></div>
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}</span>
</div>
);
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}>
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); removeSong(realIdx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
<Trash2 size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
{!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
</React.Fragment>
);
})}
</div>
{/* ── Suggestions ── */}
<div className="playlist-suggestions tracklist">
<div className="playlist-suggestions-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
<button
className="btn btn-surface"
onClick={() => loadSuggestions(songs)}
disabled={loadingSuggestions || songs.length === 0}
data-tooltip={t('playlists.refreshSuggestions')}
>
<RefreshCw size={14} className={loadingSuggestions ? 'spin-slow' : ''} />
{t('playlists.refreshSuggestions')}
</button>
</div>
{!loadingSuggestions && suggestions.filter(s => !existingIds.has(s.id)).length === 0 && (
<div className="empty-state" style={{ padding: '1.5rem 0', fontSize: '0.85rem' }}>{t('playlists.noSuggestions')}</div>
)}
{suggestions.filter(s => !existingIds.has(s.id)).length > 0 && (
<>
<div className="tracklist-header tracklist-va" style={{ ...gridStyle, marginTop: 'var(--space-3)' }}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return <div key="num" className="col-center">#</div>;
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
if (key === 'delete') return <div key="delete" />;
if (key === 'favorite' || key === 'rating') return <div key={key} />;
return <div key={key} className={isCentered ? 'col-center' : ''} style={!isCentered ? { paddingLeft: 12 } : undefined}>{label}</div>;
})}
</div>
{suggestions.filter(s => !existingIds.has(s.id)).map((song, idx) => (
<div
key={song.id}
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={gridStyle}
onMouseEnter={() => setHoveredSuggestionId(song.id)}
onMouseLeave={() => setHoveredSuggestionId(null)}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
addSong(song);
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return <div key="num" className="track-num" style={{ color: 'var(--text-muted)' }}>{idx + 1}</div>;
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'favorite': return <div key="favorite" />;
case 'rating': return <div key="rating" />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }} onClick={e => { e.stopPropagation(); addSong(song); }} data-tooltip={t('playlists.addSong')} data-tooltip-pos="left">
<Plus size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
))}
</>
)}
</div>
{editingMeta && playlist && (
<PlaylistEditModal
playlist={playlist}
customCoverId={customCoverId}
customCoverFetchUrl={customCoverFetchUrl ?? null}
customCoverCacheKey={customCoverCacheKey ?? null}
coverQuadUrls={coverQuadUrls}
onClose={() => setEditingMeta(false)}
onSave={handleSaveMeta}
/>
)}
{csvImportReport && (
<CsvImportReportModal
report={csvImportReport}
playlistName={playlist?.name || 'Unknown Playlist'}
onClose={() => setCsvImportReport(null)}
/>
)}
</div>
);
}
// ── Playlist Edit Modal ───────────────────────────────────────────────────────
interface EditModalProps {
playlist: SubsonicPlaylist;
customCoverId: string | null;
customCoverFetchUrl: string | null;
customCoverCacheKey: string | null;
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
onClose: () => void;
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
}
function PlaylistEditModal({
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
coverQuadUrls, onClose, onSave,
}: EditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(playlist.name);
const [comment, setComment] = useState(playlist.comment ?? '');
const [isPublic, setIsPublic] = useState(playlist.public ?? false);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [coverRemoved, setCoverRemoved] = useState(false);
const [saving, setSaving] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);
const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setCoverFile(file);
setCoverRemoved(false);
const reader = new FileReader();
reader.onload = ev => setCoverPreview(ev.target?.result as string);
reader.readAsDataURL(file);
};
const handleRemoveCover = (e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(null);
setCoverPreview(null);
setCoverRemoved(true);
};
const handleSave = async () => {
setSaving(true);
try {
await onSave({ name, comment, isPublic, coverFile, coverRemoved });
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content playlist-edit-modal" onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 22 }}>{t('playlists.editMeta')}</h2>
<div className="playlist-edit-body">
{/* Left: cover */}
<div
className="playlist-edit-cover-wrap"
onClick={() => coverInputRef.current?.click()}
>
{coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-edit-cover-overlay">
<div className="playlist-edit-cover-menu">
<button
className="playlist-edit-cover-menu-item"
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
>
<Camera size={14} />
{t('playlists.changeCoverLabel')}
</button>
{hasExistingCover && (
<button
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
onClick={handleRemoveCover}
>
{t('playlists.removeCover')}
</button>
)}
</div>
</div>
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
</div>
{/* Right: fields */}
<div className="playlist-edit-fields">
<input
className="input playlist-edit-name-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('playlists.editNamePlaceholder')}
autoFocus
/>
<textarea
className="input playlist-edit-desc-input"
value={comment}
onChange={e => setComment(e.target.value)}
placeholder={t('playlists.editCommentPlaceholder')}
/>
</div>
</div>
<div className="modal-actions">
<button className="btn btn-surface" onClick={onClose}>
{t('playlists.editCancel')}
</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('playlists.editSave')}
</button>
</div>
</div>
</div>
);
}
// ── CSV Import Report Modal ───────────────────────────────────────────────────
interface CsvReportModalProps {
report: {
added: number;
notFound: SpotifyCsvTrack[];
duplicates: number;
duplicateTracks: SpotifyCsvTrack[];
total: number;
searchErrors?: SpotifyCsvTrack[];
};
playlistName: string;
onClose: () => void;
}
function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalProps) {
const { t } = useTranslation();
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
const downloadReport = () => {
try {
const content = [
'CSV Import Report',
`Playlist: ${playlistName}`,
`Date: ${new Date().toLocaleString()}`,
`Total: ${report.total}, Added: ${report.added}, Duplicates: ${report.duplicates}, Not Found: ${report.notFound.length}${report.searchErrors ? `, Network Errors: ${report.searchErrors.length}` : ''}`,
'',
...(report.duplicateTracks.length > 0 ? ['Duplicate Tracks (skipped):', ...report.duplicateTracks.map(t => `- ${t.trackName} by ${t.artistName}${t.albumName ? ` (${t.albumName})` : ''}`), ''] : []),
...(report.notFound.length > 0 ? ['Not Found Tracks:', ...report.notFound.map(t => `- ${t.trackName} by ${t.artistName}${t.albumName ? ` (${t.albumName})` : ''}`), ''] : []),
...(report.searchErrors && report.searchErrors.length > 0 ? ['Network Error Tracks (may retry):', ...report.searchErrors.map(t => `- ${t.trackName} by ${t.artistName}`), ''] : []),
].join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
// Nombre detallado: playlist + fecha-hora-segundos
const timestamp = new Date().toISOString()
.replace(/[:.]/g, '-')
.slice(0, 19);
const safePlaylistName = playlistName.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 50);
a.download = `import-report-${safePlaylistName}-${timestamp}.txt`;
a.href = url;
a.click();
URL.revokeObjectURL(url);
showToast('Report downloaded successfully', 3000, 'info');
} catch (err) {
console.error('Failed to download report:', err);
showToast('Failed to download report', 3000, 'error');
}
};
return createPortal(
<div
className="modal-overlay modal-overlay--csv"
onClick={handleOverlayClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
}}
>
<div
className="modal-content"
style={{
maxWidth: 500,
maxHeight: '80vh',
width: '90%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
onClick={e => e.stopPropagation()}
>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ position: 'absolute', top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 20, marginBottom: 16 }}>{t('playlists.csvImportReport')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: report.searchErrors && report.searchErrors.length > 0 ? 'repeat(5, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 20, textAlign: 'center' }}>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>{report.total}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportTotal')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{report.added}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportAdded')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-muted)' }}>{report.duplicates}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicates')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: report.notFound.length > 0 ? '#ff6b6b' : 'var(--text-muted)' }}>{report.notFound.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFound')}</div>
</div>
{report.searchErrors && report.searchErrors.length > 0 && (
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#ffa500' }}>{report.searchErrors.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNetworkErrors')}</div>
</div>
)}
</div>
{report.duplicateTracks.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicatesTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.duplicateTracks.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
{report.notFound.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFoundTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 200, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.notFound.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
{report.searchErrors && report.searchErrors.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: '#ffa500' }}>{t('playlists.csvImportNetworkErrorsTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.searchErrors.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
<div className="modal-actions" style={{ marginTop: 'auto' }}>
<button className="btn btn-surface" onClick={downloadReport}>
<Download size={14} /> {t('playlists.csvImportDownloadReport')}
</button>
<button className="btn btn-primary" onClick={onClose}>
{t('playlists.csvImportClose')}
</button>
</div>
</div>
</div>,
document.body
);
}