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)',
],
},
{