import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle } from 'lucide-react';
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import {
getPlaylist, updatePlaylist, search, setRating, star, unstar,
getRandomSongs, SubsonicPlaylist, SubsonicSong,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
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(' · ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
const [hover, setHover] = React.useState(0);
return (
{[1, 2, 3, 4, 5].map(n => (
))}
);
}
export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = usePlayerStore();
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const { startDrag, isDragging } = useDragDrop();
const [playlist, setPlaylist] = useState(null);
const [songs, setSongs] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [ratings, setRatings] = useState>({});
const [starredSongs, setStarredSongs] = useState>(new Set());
const [hoveredSongId, setHoveredSongId] = useState(null);
const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null);
const [contextMenuSongId, setContextMenuSongId] = useState(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
// ── Bulk select ───────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState(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 next = songs.filter(s => !selectedIds.has(s.id));
setSongs(next);
savePlaylist(next);
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();
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 bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey, false);
// Song search
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false);
const searchDebounce = useRef | null>(null);
// Suggestions
const [suggestions, setSuggestions] = useState([]);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
// DnD
const tracklistRef = useRef(null);
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);
const init: Record = {};
const starred = new Set();
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 = {};
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[]) => {
if (!id) return;
setSaving(true);
try {
await updatePlaylist(id, updatedSongs.map(s => s.id));
if (id) touchPlaylist(id);
} catch {}
setSaving(false);
}, [id, touchPlaylist]);
// ── Remove ────────────────────────────────────────────────────
const removeSong = (idx: number) => {
const next = songs.filter((_, i) => i !== idx);
setSongs(next);
savePlaylist(next);
};
// ── 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 }));
setRating(songId, rating).catch(() => {});
};
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const isStarred = starredSongs.has(song.id);
setStarredSongs(prev => {
const next = new Set(prev);
isStarred ? next.delete(song.id) : next.add(song.id);
return next;
});
(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);
};
// ── 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 (
);
}
if (!playlist) {
return {t('playlists.notFound')}
;
}
const existingIds = new Set(songs.map(s => s.id));
return (
{/* ── Hero ── */}
{resolvedBgUrl && (
)}
{/* 2×2 cover grid */}
{coverQuadUrls.map((entry, i) =>
entry
?
:
)}
{t('playlists.titleBadge')}
{playlist.name}
{t('playlists.songs', { n: songs.length })}
{songs.length > 0 && · {totalDurationLabel(songs)}}
{saving && }
{/* ── Song search panel ── */}
{searchOpen && (
setSearchQuery(e.target.value)}
autoFocus
/>
{searchQuery && (
)}
{searching &&
}
{!searching && searchQuery && searchResults.length === 0 && (
{t('playlists.noResults')}
)}
{searchResults.map(song => (
{song.title}
{song.artist} · {song.album}
{formatDuration(song.duration ?? 0)}
))}
)}
{/* ── Tracklist ── */}
{/* Bulk action bar */}
{selectedIds.size > 0 && (
{t('common.bulkSelected', { count: selectedIds.size })}
{showBulkPlPicker && (
{ setShowBulkPlPicker(false); setSelectedIds(new Set()); }}
dropDown
/>
)}
)}
{/* Header */}
0 ? 'pointer' : undefined }} onClick={songs.length > 0 ? toggleAll : undefined}>
{selectedIds.size > 0
?
: '#'}
{t('albumDetail.trackTitle')}
{t('albumDetail.trackArtist')}
{t('albumDetail.trackFavorite')}
{t('albumDetail.trackRating')}
{t('albumDetail.trackDuration')}
{t('albumDetail.trackFormat')}
{songs.length === 0 && (
{t('playlists.emptyPlaylist')}
)}
{songs.map((song, idx) => (
{/* Drop indicator above row */}
{isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && (
)}
{ setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }}
onMouseLeave={() => setHoveredSongId(null)}
onMouseDown={e => handleRowMouseDown(e, idx)}
onDoubleClick={() => {
const tracks = songs.map(songToTrack);
playTrack(tracks[idx], tracks);
}}
onClick={e => {
if (selectedIds.size > 0 && !(e.target as HTMLElement).closest('button, input')) {
toggleSelect(song.id, idx, e.shiftKey);
}
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
{/* # — checkbox in select mode, grip/play on hover otherwise */}
{(() => {
const inSelectMode = selectedIds.size > 0;
return (
{
e.stopPropagation();
if (inSelectMode || hoveredSongId === song.id) {
toggleSelect(song.id, idx, e.shiftKey);
} else {
const tracks = songs.map(songToTrack);
playTrack(tracks[idx], tracks);
}
}}
>
{ e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
/>
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
?
: currentTrack?.id === song.id && isPlaying
?
: currentTrack?.id === song.id
?
: idx + 1}
);
})()}
{/* Title */}
{song.title}
{/* Artist */}
{song.artist}
{/* Favorite */}
{/* Rating */}
handleRate(song.id, r)} />
{/* Duration */}
{formatDuration(song.duration ?? 0)}
{/* Format */}
{(song.suffix || song.bitRate) && {codecLabel(song)}}
{/* Delete */}
{/* Drop indicator below last row or between rows */}
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
)}
))}
{/* Total row */}
{songs.length > 0 && (
{t('albumDetail.trackTotal')}
{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}
)}
{/* ── Suggestions ── */}
{t('playlists.suggestions')}
{!loadingSuggestions && suggestions.filter(s => !existingIds.has(s.id)).length === 0 && (
{t('playlists.noSuggestions')}
)}
{suggestions.filter(s => !existingIds.has(s.id)).length > 0 && (
<>
#
{t('albumDetail.trackTitle')}
{t('albumDetail.trackArtist')}
{t('albumDetail.trackDuration')}
{t('albumDetail.trackFormat')}
{suggestions.filter(s => !existingIds.has(s.id)).map((song, idx) => (
setHoveredSuggestionId(song.id)}
onMouseLeave={() => setHoveredSuggestionId(null)}
onDoubleClick={() => addSong(song)}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
{idx + 1}
{song.title}
{song.artist}
{/* no star/rating for suggestions */}
{formatDuration(song.duration ?? 0)}
{(song.suffix || song.bitRate) && {codecLabel(song)}}
))}
>
)}
);
}