From a090ad283200f7dafdbd372d5000deb728b38be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=AEArtem?= Date: Mon, 18 May 2026 13:18:49 +0300 Subject: [PATCH] feat(playlist): virtualize tracklist + memoize rows (#755) * feat(playlist): virtualize tracklist + memoize rows * fix(playlist): separator in virtual row key to avoid id/index collision * chore(release): CHANGELOG + credits for playlist virtualization (PR #755) --- CHANGELOG.md | 6 + src/components/playlist/PlaylistRow.tsx | 157 +++++++++ src/components/playlist/PlaylistTracklist.tsx | 305 ++++++++++-------- src/config/settingsCredits.ts | 1 + 4 files changed, 332 insertions(+), 137 deletions(-) create mode 100644 src/components/playlist/PlaylistRow.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 82803b7a..547109ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -268,7 +268,13 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * **Responsive layout:** **3** cards in one row on 2K-class screens, **2** cards in one row at 1080p (the orphan third card on a second row is hidden via container query), and all **3** stacked vertically on truly narrow / mobile widths. * Toggleable in the Home customizer like every other rail; respects the existing performance flags ("Disable rail artwork", "Disable Home album rows"). +### Playlists — virtualized tracklist for large playlists +**By [@artplan1](https://github.com/artplan1), PR [#755](https://github.com/Psychotoxical/psysonic/pull/755)** + +* Opening a very large playlist (10 000+ tracks) no longer mounts every row into the DOM. The playlist tracklist is windowed with `@tanstack/react-virtual` on the shared app scroll viewport — the same convention as Artists, Composers, and the library card grids. +* Row rendering moved into a memoized `PlaylistRow` with a stable callback bundle so virtualizer scroll updates do not re-render the full list. +* Drag-and-drop reordering is preserved: drop-indicator overlay and edge auto-scroll during drags. ## Changed diff --git a/src/components/playlist/PlaylistRow.tsx b/src/components/playlist/PlaylistRow.tsx new file mode 100644 index 00000000..50eca6ba --- /dev/null +++ b/src/components/playlist/PlaylistRow.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AudioLines, ChevronRight, Heart, Play, Square, Trash2 } from 'lucide-react'; +import type { ColDef } from '../../utils/useTracklistColumns'; +import type { SubsonicSong } from '../../api/subsonicTypes'; +import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers'; +import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers'; +import i18n from '../../i18n'; +import { formatTrackTime } from '../../utils/format/formatDuration'; +import StarRating from '../StarRating'; + +export interface PlaylistRowCallbacks { + activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void; + dblOrbit: (songId: string, e: React.MouseEvent) => void; + context: (song: SubsonicSong, realIdx: number, e: React.MouseEvent) => void; + mouseDownRow: (realIdx: number, e: React.MouseEvent) => void; + mouseEnterRow: (index: number, e: React.MouseEvent) => void; + toggleSelect: (songId: string, index: number, shift: boolean) => void; + play: (index: number) => void; + startPreview: (song: SubsonicSong) => void; + toggleStar: (song: SubsonicSong, e: React.MouseEvent) => void; + rate: (songId: string, rating: number) => void; + remove: (realIdx: number) => void; + navArtist: (artistId: string) => void; + navAlbum: (albumId: string) => void; +} + +interface Props { + song: SubsonicSong; + index: number; + realIdx: number; + visibleCols: ColDef[]; + gridStyle: React.CSSProperties; + showBitrate: boolean; + isActive: boolean; + showEq: boolean; + isContextActive: boolean; + isSelected: boolean; + inSelectMode: boolean; + isStarred: boolean; + ratingValue: number; + isPreviewing: boolean; + previewStarted: boolean; + orbitActive: boolean; + cb: PlaylistRowCallbacks; +} + +function PlaylistRow({ + song, index: i, realIdx, visibleCols, gridStyle, showBitrate, + isActive, showEq, isContextActive, isSelected, inSelectMode, + isStarred, ratingValue, isPreviewing, previewStarted, orbitActive, cb, +}: Props) { + const { t } = useTranslation(); + + return ( +
cb.mouseEnterRow(i, e)} + onMouseDown={e => cb.mouseDownRow(realIdx, e)} + onClick={e => cb.activate(song, i, e)} + onDoubleClick={orbitActive ? e => cb.dblOrbit(song.id, e) : undefined} + onContextMenu={e => cb.context(song, realIdx, e)} + > + {visibleCols.map(colDef => { + switch (colDef.key) { + case 'num': return ( +
+ { e.stopPropagation(); cb.toggleSelect(song.id, i, e.shiftKey); }} /> + {showEq ? ( + + ) : ( + {i + 1} + )} +
+ ); + case 'title': return ( +
+ + + {song.title} +
+ ); + case 'artist': return ( +
+ { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId); } }}>{song.artist} +
+ ); + case 'album': return ( +
+ { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album} +
+ ); + case 'favorite': return ( +
+ +
+ ); + case 'rating': return cb.rate(song.id, r)} />; + case 'duration': return
{formatTrackTime(song.duration ?? 0)}
; + case 'format': return ( +
+ {(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} +
+ ); + case 'genre': return ( +
{song.genre ?? '—'}
+ ); + case 'playCount': return ( +
{song.playCount ?? '—'}
+ ); + case 'lastPlayed': return ( +
{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}
+ ); + case 'bpm': return ( +
{song.bpm && song.bpm > 0 ? song.bpm : '—'}
+ ); + case 'delete': return ( +
+ +
+ ); + default: return null; + } + })} +
+ ); +} + +export default React.memo(PlaylistRow); diff --git a/src/components/playlist/PlaylistTracklist.tsx b/src/components/playlist/PlaylistTracklist.tsx index f1905d5d..558ce166 100644 --- a/src/components/playlist/PlaylistTracklist.tsx +++ b/src/components/playlist/PlaylistTracklist.tsx @@ -1,8 +1,12 @@ -import React from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import PlaylistRow, { type PlaylistRowCallbacks } from './PlaylistRow'; import { useTranslation } from 'react-i18next'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll'; +import { useElementClientHeightById } from '../../hooks/useResizeClientHeight'; import { useNavigate } from 'react-router-dom'; import { - AudioLines, Check, ChevronDown, ChevronRight, Heart, ListPlus, Play, RotateCcw, Search, Square, Trash2, X, + Check, ChevronDown, ListPlus, RotateCcw, Search, Trash2, X, } from 'lucide-react'; import type { ColDef } from '../../utils/useTracklistColumns'; import type { SubsonicSong } from '../../api/subsonicTypes'; @@ -13,12 +17,7 @@ import { useThemeStore } from '../../store/themeStore'; import { useDragDrop } from '../../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; -import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers'; -import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers'; -import i18n from '../../i18n'; -import { formatTrackTime } from '../../utils/format/formatDuration'; import type { PlaylistSortKey, PlaylistSortDir } from '../../utils/playlist/playlistDisplayedSongs'; -import StarRating from '../StarRating'; import { AddToPlaylistSubmenu } from '../ContextMenu'; const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']); @@ -106,6 +105,131 @@ export default function PlaylistTracklist({ const { isDragging } = useDragDrop(); const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); + const latestVals = { + selectedIds, orbitActive, displayedTracks, isFiltered, id, + toggleSelect, handleRowMouseDown, handleRowMouseEnter, handleToggleStar, + handleRate, removeSong, playTrack, openContextMenu, setContextMenuSongId, + navigate, queueHint, addTrackToOrbit, + }; + const latest = useRef(latestVals); + latest.current = latestVals; + + const cb = useMemo(() => ({ + activate: (song, index, e) => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + const L = latest.current; + if (e.ctrlKey || e.metaKey) L.toggleSelect(song.id, index, false); + else if (L.selectedIds.size > 0) L.toggleSelect(song.id, index, e.shiftKey); + else if (L.orbitActive) L.queueHint(); + else L.playTrack(L.displayedTracks[index], L.displayedTracks); + }, + dblOrbit: (songId, e) => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + const L = latest.current; + if (e.ctrlKey || e.metaKey || L.selectedIds.size > 0) return; + L.addTrackToOrbit(songId); + }, + context: (song, rIdx, e) => { + e.preventDefault(); + const L = latest.current; + L.setContextMenuSongId(song.id); + L.openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, L.id, rIdx); + }, + mouseDownRow: (rIdx, e) => latest.current.handleRowMouseDown(e, rIdx), + mouseEnterRow: (index, e) => { const L = latest.current; if (!L.isFiltered) L.handleRowMouseEnter(index, e); }, + toggleSelect: (songId, index, shift) => latest.current.toggleSelect(songId, index, shift), + play: (index) => { + const L = latest.current; + if (L.orbitActive) { L.queueHint(); return; } + L.playTrack(L.displayedTracks[index], L.displayedTracks); + }, + startPreview: (song) => usePreviewStore.getState().startPreview( + { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, + 'playlists', + ), + toggleStar: (song, e) => latest.current.handleToggleStar(song, e), + rate: (songId, r) => latest.current.handleRate(songId, r), + remove: (rIdx) => latest.current.removeSong(rIdx), + navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`), + navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`), + }), []); + + const listWrapRef = useRef(null); + const [scrollMargin, setScrollMargin] = useState(0); + const viewportH = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); + + useLayoutEffect(() => { + const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); + const root = tracklistRef.current?.closest('.album-detail') as HTMLElement | null; + if (!sc) return; + const measure = () => { + const wrap = listWrapRef.current; + if (!wrap) return; + const m = wrap.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop; + setScrollMargin(prev => (Math.abs(prev - m) > 0.5 ? m : prev)); + }; + const ro = new ResizeObserver(measure); + ro.observe(sc); + if (root) ro.observe(root); + measure(); + return () => ro.disconnect(); + }, [tracklistRef, selectedIds.size > 0, pickerOpen, displayedSongs.length]); + + const rowVirtualizer = useVirtualizer({ + count: displayedSongs.length, + getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + estimateSize: () => 48, + overscan: Math.max(8, Math.ceil(viewportH / 48)), + scrollMargin, + getItemKey: i => `${displayedSongs[i].id}:${i}`, + }); + + const firstRender = useRef(true); + useEffect(() => { + if (firstRender.current) { firstRender.current = false; return; } + const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); + if (sc) sc.scrollTop = scrollMargin; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, isFiltered]); + + const autoScrollRef = useRef(0); + const pointerYRef = useRef(0); + const runAutoScroll = useCallback(() => { + const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); + if (!sc) { autoScrollRef.current = 0; return; } + const r = sc.getBoundingClientRect(); + const EDGE = 60; + const MAX = 18; + const y = pointerYRef.current; + let dy = 0; + if (y < r.top + EDGE) dy = -MAX * (1 - (y - r.top) / EDGE); + else if (y > r.bottom - EDGE) dy = MAX * (1 - (r.bottom - y) / EDGE); + if (dy !== 0) sc.scrollTop += dy; + autoScrollRef.current = requestAnimationFrame(runAutoScroll); + }, []); + + useEffect(() => { + if (!isDragging) return; + const onMove = (e: MouseEvent) => { pointerYRef.current = e.clientY; }; + window.addEventListener('mousemove', onMove); + autoScrollRef.current = requestAnimationFrame(runAutoScroll); + return () => { + window.removeEventListener('mousemove', onMove); + if (autoScrollRef.current) cancelAnimationFrame(autoScrollRef.current); + autoScrollRef.current = 0; + }; + }, [isDragging, runAutoScroll]); + + const virtualItems = rowVirtualizer.getVirtualItems(); + + let dropIndicatorY: number | null = null; + if (isDragging && !isFiltered && dropTargetIdx) { + const vi = virtualItems.find(v => v.index === dropTargetIdx.idx); + const start = vi ? vi.start : dropTargetIdx.idx * 48 + scrollMargin; + const size = vi ? vi.size : 48; + dropIndicatorY = (dropTargetIdx.before ? start : start + size) - scrollMargin; + } + return (
@@ -309,140 +433,47 @@ export default function PlaylistTracklist({
)} - {displayedSongs.map((song, i) => { +
+ {dropIndicatorY !== null && ( +
+ )} + {virtualItems.map(vi => { + const song = displayedSongs[vi.index]; + const i = vi.index; const realIdx = isFiltered ? songs.indexOf(song) : i; return ( - - {!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && ( -
- )} -
!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 if (orbitActive) { - queueHint(); - } else { - playTrack(displayedTracks[i], displayedTracks); - } - }} - onDoubleClick={orbitActive ? e => { - if ((e.target as HTMLElement).closest('button, a, input')) return; - if (e.ctrlKey || e.metaKey || selectedIds.size > 0) return; - addTrackToOrbit(song.id); - } : undefined} - 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 ( -
- { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> - {currentTrack?.id === song.id && isPlaying ? ( - - ) : ( - {i + 1} - )} -
- ); - case 'title': return ( -
- - - {song.title} -
- ); - case 'artist': return ( -
- { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist} -
- ); - case 'album': return ( -
- { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album} -
- ); - case 'favorite': return ( -
- -
- ); - case 'rating': return handleRate(song.id, r)} />; - case 'duration': return
{formatTrackTime(song.duration ?? 0)}
; - case 'format': return ( -
- {(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} -
- ); - case 'genre': return ( -
{song.genre ?? '—'}
- ); - case 'playCount': return ( -
{song.playCount ?? '—'}
- ); - case 'lastPlayed': return ( -
{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}
- ); - case 'bpm': return ( -
{song.bpm && song.bpm > 0 ? song.bpm : '—'}
- ); - case 'delete': return ( -
- -
- ); - default: return null; - } - })} -
- {!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && ( -
- )} - +
+ 0} + isStarred={song.id in starredOverrides ? !!starredOverrides[song.id] : starredSongs.has(song.id)} + ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} + isPreviewing={previewingId === song.id} + previewStarted={previewingId === song.id && previewAudioStarted} + orbitActive={orbitActive} + cb={cb} + /> +
); })} +
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 25b1f7d3..87f60aee 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -212,6 +212,7 @@ const CONTRIBUTOR_ENTRIES = [ since: '1.46.0', contributions: [ 'Player: cap persisted queue to ±250-track window — fixes QuotaExceededError on large playlists (PR #756)', + 'Playlists: virtualized tracklist for large playlists — no UI freeze on 10k+ tracks (PR #755)', ], }, {