mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
1624880c2a
Apply the same per-axis star cutoff to random mix (multi-batch fetch until full), random albums, Hero, and Home hero/discover rows. Prefetch artist and album ratings via Subsonic when list payloads omit them. Exclude items when 0 < rating ≤ threshold; 0 or missing counts as unrated. Settings: rating filter description interpolates sidebar menu labels; Russian strings for custom title bar; short axis labels aligned across locales.
1133 lines
52 KiB
TypeScript
1133 lines
52 KiB
TypeScript
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||
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 } 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 { useAuthStore } from '../store/authStore';
|
||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||
import { join } from '@tauri-apps/api/path';
|
||
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 StarRating from '../components/StarRating';
|
||
|
||
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);
|
||
const h = Math.floor(total / 3600);
|
||
const m = Math.floor((total % 3600) / 60);
|
||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||
}
|
||
|
||
function codecLabel(song: SubsonicSong): string {
|
||
const parts: string[] = [];
|
||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
// ── 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: '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, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
|
||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||
const downloadFolder = useAuthStore(s => s.downloadFolder);
|
||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||
|
||
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 [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 [downloadProgress, setDownloadProgress] = useState<number | 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);
|
||
|
||
// 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 ─────────────────────────────────────────────────────
|
||
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]);
|
||
|
||
// ── 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;
|
||
setDownloadProgress(0);
|
||
try {
|
||
const url = buildDownloadUrl(id);
|
||
const response = await fetch(url);
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const contentLength = response.headers.get('Content-Length');
|
||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||
if (total && response.body) {
|
||
const reader = response.body.getReader();
|
||
let received = 0;
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
chunks.push(value);
|
||
received += value.length;
|
||
setDownloadProgress(Math.round((received / total) * 100));
|
||
}
|
||
} else {
|
||
const buffer = await response.arrayBuffer() as ArrayBuffer;
|
||
chunks.push(new Uint8Array(buffer));
|
||
setDownloadProgress(100);
|
||
}
|
||
const blob = new Blob(chunks);
|
||
const buffer = await blob.arrayBuffer();
|
||
const path = await join(folder, `${sanitizeFilename(playlist.name)}.zip`);
|
||
await writeFile(path, new Uint8Array(buffer));
|
||
} catch (e) {
|
||
console.error('Download failed:', e);
|
||
setDownloadProgress(null);
|
||
} finally {
|
||
setTimeout(() => setDownloadProgress(null), 60000);
|
||
}
|
||
};
|
||
|
||
// ── 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);
|
||
startDrag(
|
||
{ data: JSON.stringify({ type: 'playlist_reorder', index: 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]);
|
||
|
||
// ── 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 && (
|
||
<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 */}
|
||
<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([]); }}
|
||
>
|
||
<Search size={16} /> {t('playlists.addSongs')}
|
||
</button>
|
||
{songs.length > 0 && id && (() => {
|
||
const isDownloading = isAlbumDownloading(id);
|
||
const isCached = isAlbumDownloaded(id, activeServerId);
|
||
const progress = isDownloading ? getAlbumProgress(id) : null;
|
||
return (
|
||
<button
|
||
className="btn btn-ghost"
|
||
disabled={isDownloading}
|
||
onClick={() => { if (playlist) downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId); }}
|
||
data-tooltip={isDownloading
|
||
? t('albumDetail.offlineDownloading', { n: progress?.done ?? 0, total: progress?.total ?? 0 })
|
||
: isCached ? t('playlists.offlineCached') : t('playlists.cacheOffline')}
|
||
>
|
||
{isDownloading
|
||
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||
: isCached ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||
</button>
|
||
);
|
||
})()}
|
||
{songs.length > 0 && (
|
||
downloadProgress !== null ? (
|
||
<div className="download-progress-wrap">
|
||
<Download size={14} />
|
||
<div className="download-progress-bar">
|
||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||
</div>
|
||
<span className="download-progress-pct">{downloadProgress}%</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>
|
||
)}
|
||
{searchResults.map(song => (
|
||
<div key={song.id} className="playlist-search-row" style={{ cursor: 'pointer' }} onClick={() => addSong(song)}>
|
||
<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>
|
||
)}
|
||
|
||
{/* ── 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}`) : '';
|
||
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" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||
</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} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||
<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' }}>{label}</span>
|
||
</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>
|
||
)}
|
||
|
||
{songs.map((song, idx) => (
|
||
<React.Fragment key={song.id + idx}>
|
||
{isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && (
|
||
<div className="playlist-drop-indicator" />
|
||
)}
|
||
<div
|
||
data-track-idx={idx}
|
||
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 => handleRowMouseEnter(idx, e)}
|
||
onMouseDown={e => handleRowMouseDown(e, idx)}
|
||
onClick={e => {
|
||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||
if (selectedIds.size > 0) {
|
||
toggleSelect(song.id, idx, e.shiftKey);
|
||
} else {
|
||
playTrack(tracks[idx], tracks);
|
||
}
|
||
}}
|
||
onContextMenu={e => {
|
||
e.preventDefault();
|
||
setContextMenuSongId(song.id);
|
||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||
}}
|
||
>
|
||
{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(tracks[idx], tracks); }}>
|
||
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, 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">{idx + 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 '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 || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||
</div>
|
||
);
|
||
case 'delete': return (
|
||
<div key="delete" className="playlist-row-delete-cell">
|
||
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); removeSong(idx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
|
||
<Trash2 size={13} />
|
||
</button>
|
||
</div>
|
||
);
|
||
default: return null;
|
||
}
|
||
})}
|
||
</div>
|
||
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
|
||
<div className="playlist-drop-indicator" />
|
||
)}
|
||
</React.Fragment>
|
||
))}
|
||
|
||
{/* Total row */}
|
||
{songs.length > 0 && (
|
||
<div className="tracklist-total" style={gridStyle}>
|
||
{visibleCols.map(c => {
|
||
if (c.key === 'title') return <span key="title" className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>;
|
||
if (c.key === 'duration') return <span key="duration" className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>;
|
||
return <span key={c.key} />;
|
||
})}
|
||
</div>
|
||
)}
|
||
</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 || song.bitRate) && <span className="track-codec">{codecLabel(song)}</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}
|
||
/>
|
||
)}
|
||
</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="playlist-edit-footer">
|
||
<label className="toggle-label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
|
||
<label className="toggle-switch" style={{ marginBottom: 0 }}>
|
||
<input type="checkbox" checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('playlists.editPublic')}</span>
|
||
</label>
|
||
<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>
|
||
);
|
||
}
|