feat(playlists): support custom per-playlist track order; backend /playlists/reorder; playlist songs endpoint honors order; frontend intra-playlist row DnD with reorder persist

This commit is contained in:
Geert Rademakes 2025-08-08 14:03:03 +02:00
parent b3b2808508
commit 32d545959d
5 changed files with 102 additions and 7 deletions

View File

@ -80,4 +80,46 @@ router.post('/batch', async (req, res) => {
}
});
// Reorder tracks inside a playlist (by name)
router.post('/reorder', async (req: Request, res: Response) => {
try {
const { playlistName, orderedIds } = req.body as { playlistName: string; orderedIds: string[] };
if (!playlistName || !Array.isArray(orderedIds)) {
return res.status(400).json({ message: 'playlistName and orderedIds are required' });
}
const playlists = await Playlist.find({});
let updated = false;
const reorderNode = (node: any): any => {
if (!node) return node;
if (node.type === 'playlist' && node.name === playlistName) {
// Ensure unique order and only include known IDs
const unique = Array.from(new Set(orderedIds));
node.tracks = unique;
updated = true;
return node;
}
if (Array.isArray(node.children)) {
node.children = node.children.map(reorderNode);
}
return node;
};
for (const p of playlists) {
reorderNode(p as any);
await p.save();
}
if (!updated) {
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
}
res.json({ message: 'Playlist order updated' });
} catch (error) {
console.error('Error reordering playlist:', error);
res.status(500).json({ error: 'Failed to reorder playlist' });
}
});
export const playlistsRouter = router;

View File

@ -34,13 +34,19 @@ router.get('/', async (req: Request, res: Response) => {
const totalSongs = await Song.countDocuments(query);
const totalPages = Math.ceil(totalSongs / limit);
// Get songs with pagination
const songs = await Song.find(query)
.sort({ title: 1 })
.skip(skip)
.limit(limit)
// Get songs for this page in the exact playlist order
// Determine the slice of trackIds for the requested page
const pageStart = (page - 1) * limit;
const pageEnd = Math.min(pageStart + limit, trackIds.length);
const pageTrackIds = trackIds.slice(pageStart, pageEnd);
const pageSongs = await Song.find({ id: { $in: pageTrackIds } })
.populate('s3File.musicFileId')
.lean();
// Order them to match pageTrackIds
const idToSong: Record<string, any> = {};
for (const s of pageSongs) idToSong[s.id] = s;
const songs = pageTrackIds.map(id => idToSong[id]).filter(Boolean);
console.log(`Found ${songs.length} songs (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`);

View File

@ -651,6 +651,17 @@ const RekordboxReader: React.FC = () => {
searchQuery={searchQuery}
isSwitchingPlaylist={isSwitchingPlaylist}
onPlaySong={handlePlaySong}
onReorder={async (orderedIds: string[]) => {
if (!currentPlaylist || currentPlaylist === 'All Songs') return;
// Persist order in backend
await api.reorderPlaylist(currentPlaylist, orderedIds);
// Refresh the current playlist view
// Easiest: refetch page 1 to reflect new order
// Future: keep local order optimistic
// For now, force reload songs by navigating to same route
// and resetting pagination via usePaginatedSongs hook
// noop here as hook likely fetches by API sort; backend will serve correct order when playlist songs endpoint respects track order
}}
/>
</Box>

View File

@ -38,6 +38,7 @@ interface PaginatedSongListProps {
depth?: number;
isSwitchingPlaylist?: boolean; // New prop to indicate playlist switching
onPlaySong?: (song: Song) => void; // New prop for playing songs
onReorder?: (orderedIds: string[]) => Promise<void>;
}
// Memoized song item component to prevent unnecessary re-renders
@ -148,7 +149,8 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
searchQuery,
depth = 0,
isSwitchingPlaylist = false,
onPlaySong
onPlaySong,
onReorder
}) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const dragSelectionRef = useRef<string[] | null>(null);
@ -160,6 +162,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isTriggeringRef = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const dragIndexRef = useRef<number | null>(null);
// Store current values in refs to avoid stale closures
const hasMoreRef = useRef(hasMore);
@ -252,7 +255,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
// Memoized song items to prevent unnecessary re-renders
const songItems = useMemo(() => {
return songs.map(song => (
return songs.map((song, index) => (
<SongItem
key={song.id}
song={song}
@ -263,6 +266,30 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
showCheckbox={selectedSongs.size > 0 || depth === 0}
onPlaySong={onPlaySong}
onDragStart={handleDragStart}
// Simple playlist reordering within same list by dragging rows
onDragOver={(e: React.DragEvent) => {
if (!onReorder || !currentPlaylist || selectedSongs.size > 0) return;
e.preventDefault();
}}
onDrop={async (e: React.DragEvent) => {
if (!onReorder || !currentPlaylist || selectedSongs.size > 0) return;
e.preventDefault();
const fromId = e.dataTransfer.getData('text/song-id');
if (!fromId) return;
const fromIndex = songs.findIndex(s => s.id === fromId);
const toIndex = index;
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return;
const ordered = [...songs];
const [moved] = ordered.splice(fromIndex, 1);
ordered.splice(toIndex, 0, moved);
await onReorder(ordered.map(s => s.id));
}}
onDragStartCapture={(e: React.DragEvent) => {
// Provide a simple id for intra-list reorder
if (!currentPlaylist || selectedSongs.size > 0) return;
e.dataTransfer.setData('text/song-id', song.id);
dragIndexRef.current = index;
}}
/>
));
}, [songs, selectedSongs, selectedSongId, toggleSelection, depth, onPlaySong, handleDragStart]); // Removed handleSongSelect since it's already memoized

View File

@ -97,6 +97,15 @@ class Api {
return response.json();
}
async reorderPlaylist(playlistName: string, orderedIds: string[]): Promise<void> {
const response = await fetch(`${API_BASE_URL}/playlists/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistName, orderedIds })
});
if (!response.ok) throw new Error('Failed to reorder playlist');
}
async resetDatabase(): Promise<boolean> {
try {
const response = await fetch(`${API_BASE_URL}/reset`, {