diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e130ccf..1338fa94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -349,6 +349,14 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * 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. +### Favorites — virtualized songs tracklist for large collections + +**By [@artplan1](https://github.com/artplan1), PR [#805](https://github.com/Psychotoxical/psysonic/pull/805)** + +* Opening Favorites with 10 000+ starred songs no longer mounts every row into the DOM. The songs tracklist is windowed with `@tanstack/react-virtual` on the shared app scroll viewport — same shape as the playlist virtualization fix. +* Row rendering moved into a memoized `FavoriteSongRow` with a stable callback bundle; `visibleTracks` is memoized once per filtered song list. +* Drag-out, preview, orbit, context menu, bulk select, and column picker behaviour are unchanged. + ## Changed ### Build — lazy-loaded routes and Vite chunk warnings diff --git a/src/components/favorites/FavoriteSongRow.tsx b/src/components/favorites/FavoriteSongRow.tsx new file mode 100644 index 00000000..5e069a2e --- /dev/null +++ b/src/components/favorites/FavoriteSongRow.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AudioLines, ChevronRight, Play, Square, X } 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 FavoriteSongRowCallbacks { + activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void; + dblOrbit: (songId: string, e: React.MouseEvent) => void; + context: (song: SubsonicSong, e: React.MouseEvent) => void; + mouseDownRow: (song: SubsonicSong, e: React.MouseEvent) => void; + toggleSelect: (songId: string, index: number, shift: boolean) => void; + play: (index: number) => void; + startPreview: (song: SubsonicSong) => void; + rate: (songId: string, rating: number) => void; + remove: (songId: string) => void; + navArtist: (artistId: string) => void; + navAlbum: (albumId: string) => void; +} + +interface Props { + song: SubsonicSong; + index: number; + visibleCols: ColDef[]; + gridStyle: React.CSSProperties; + showBitrate: boolean; + isActive: boolean; + showEq: boolean; + isSelected: boolean; + inSelectMode: boolean; + ratingValue: number; + isPreviewing: boolean; + previewStarted: boolean; + orbitActive: boolean; + cb: FavoriteSongRowCallbacks; +} + +function FavoriteSongRow({ + song, index: i, visibleCols, gridStyle, showBitrate, + isActive, showEq, isSelected, inSelectMode, + ratingValue, isPreviewing, previewStarted, orbitActive, cb, +}: Props) { + const { t } = useTranslation(); + + return ( +
cb.activate(song, i, e)} + onDoubleClick={orbitActive ? e => cb.dblOrbit(song.id, e) : undefined} + onContextMenu={e => cb.context(song, e)} + onMouseDown={e => cb.mouseDownRow(song, 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 'genre': return ( +
{song.genre ?? '—'}
+ ); + case 'format': return ( +
+ {(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} +
+ ); + case 'rating': return cb.rate(song.id, r)} />; + case 'duration': return
{formatTrackTime(song.duration)}
; + 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 'remove': return ( +
+ +
+ ); + default: return null; + } + })} +
+ ); +} + +export default React.memo(FavoriteSongRow); diff --git a/src/components/favorites/FavoritesSongsTracklist.tsx b/src/components/favorites/FavoritesSongsTracklist.tsx index 4ec1d2e7..cb015180 100644 --- a/src/components/favorites/FavoritesSongsTracklist.tsx +++ b/src/components/favorites/FavoritesSongsTracklist.tsx @@ -1,22 +1,20 @@ -import React from 'react'; +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import FavoriteSongRow, { type FavoriteSongRowCallbacks } from './FavoriteSongRow'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { - AudioLines, Check, ChevronDown, ChevronRight, Play, RotateCcw, - Square, X, -} from 'lucide-react'; +import { Check, ChevronDown, RotateCcw } from 'lucide-react'; import type { ColDef } from '../../utils/useTracklistColumns'; import type { SubsonicSong } from '../../api/subsonicTypes'; import { usePlayerStore } from '../../store/playerStore'; import { usePreviewStore } from '../../store/previewStore'; import { useSelectionStore } from '../../store/selectionStore'; +import { useThemeStore } from '../../store/themeStore'; import { useDragDrop } from '../../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; -import { formatTrackTime } from '../../utils/format/formatDuration'; -import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers'; -import i18n from '../../i18n'; -import StarRating from '../StarRating'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll'; +import { useElementClientHeightById } from '../../hooks/useResizeClientHeight'; const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']); @@ -61,9 +59,114 @@ export default function FavoritesSongsTracklist({ const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); + const showBitrate = useThemeStore(s => s.showBitrate); const psyDrag = useDragDrop(); const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); + const visibleTracks = useMemo(() => visibleSongs.map(songToTrack), [visibleSongs]); + + const latestVals = { + visibleSongs, visibleTracks, selectedIds, inSelectMode, orbitActive, + toggleSelect, handleRate, removeSong, playTrack, openContextMenu, + navigate, queueHint, addTrackToOrbit, psyDrag, + }; + 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.inSelectMode) L.toggleSelect(song.id, index, e.shiftKey); + else if (L.orbitActive) L.queueHint(); + else L.playTrack(L.visibleTracks[index], L.visibleTracks); + }, + dblOrbit: (songId, e) => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + const L = latest.current; + if (e.ctrlKey || e.metaKey || L.inSelectMode) return; + L.addTrackToOrbit(songId); + }, + context: (song, e) => { + e.preventDefault(); + latest.current.openContextMenu(e.clientX, e.clientY, songToTrack(song), 'favorite-song'); + }, + mouseDownRow: (song, e) => { + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest('button, a, input')) return; + e.preventDefault(); + const sx = e.clientX, sy = e.clientY; + const track = songToTrack(song); + 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); + const L = latest.current; + const { selectedIds: selIds } = useSelectionStore.getState(); + if (selIds.has(song.id) && selIds.size > 1) { + const bulkTracks = L.visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack); + L.psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY); + } else { + L.psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); + } + } + }; + const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + 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.visibleTracks[index], L.visibleTracks); + }, + startPreview: (song) => usePreviewStore.getState().startPreview( + { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, + 'favorites', + ), + rate: (songId, r) => latest.current.handleRate(songId, r), + remove: (songId) => latest.current.removeSong(songId), + 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); + // scrollMargin must track height changes in sections above the list (filters, top artists). + // Intentionally coupled to the Favorites page shell class — keep in sync with that layout. + const root = tracklistRef.current?.closest('.content-body') 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(); + // selectedIds.size > 0 (boolean): bulk bar show/hide shifts listWrapRef top — remeasure on that edge only. + }, [tracklistRef, selectedIds.size > 0, pickerOpen, visibleSongs.length]); + + const rowVirtualizer = useVirtualizer({ + count: visibleSongs.length, + getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + estimateSize: () => 48, + overscan: Math.max(8, Math.ceil(viewportH / 48)), + scrollMargin, + getItemKey: i => `${visibleSongs[i].id}:${i}`, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + return (
{ if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); @@ -178,178 +281,38 @@ export default function FavoritesSongsTracklist({ })}
- {visibleSongs.map((song, i) => { - const track = songToTrack(song); - const isSelected = selectedIds.has(song.id); - return ( -
{ - if ((e.target as HTMLElement).closest('button, a, input')) return; - if (e.ctrlKey || e.metaKey) { - toggleSelect(song.id, i, false); - } else if (inSelectMode) { - toggleSelect(song.id, i, e.shiftKey); - } else if (orbitActive) { - queueHint(); - } else { - playTrack(track, visibleSongs.map(songToTrack)); - } - }} - onDoubleClick={orbitActive ? e => { - if ((e.target as HTMLElement).closest('button, a, input')) return; - if (e.ctrlKey || e.metaKey || inSelectMode) return; - addTrackToOrbit(song.id); - } : undefined} - onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'favorite-song'); }} - role="row" - onMouseDown={e => { - if (e.button !== 0) 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); - const { selectedIds: selIds } = useSelectionStore.getState(); - if (selIds.has(song.id) && selIds.size > 1) { - const bulkTracks = visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack); - psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY); - } else { - psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); - } - } - }; - const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > - {visibleCols.map(colDef => { - 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) return; - e.stopPropagation(); - navigate(`/artist/${song.artistId}`); - }} - > - {song.artist} - -
- ); - case 'album': return ( -
- {song.albumId ? ( - { - e.stopPropagation(); - navigate(`/album/${song.albumId}`); - }} - > - {song.album} - - ) : ( - {song.album} - )} -
- ); - case 'genre': return ( -
- {song.genre ?? '—'} -
- ); - case 'format': return ( -
- {(song.suffix || song.bitRate) && ( - - {song.suffix?.toUpperCase()} - {song.suffix && song.bitRate && ' · '} - {song.bitRate && `${song.bitRate} kbps`} - - )} -
- ); - case 'rating': return ( - handleRate(song.id, r)} - /> - ); - case 'duration': return ( -
- {formatTrackTime(song.duration)} -
- ); - 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 'remove': return ( -
- -
- ); - default: return null; - } - })} -
- ); - })} + +
+ {virtualItems.map(vi => { + const song = visibleSongs[vi.index]; + const i = vi.index; + return ( +
+ +
+ ); + })} +
{/* Empty state when filters return no results */} {visibleSongs.length === 0 && hasFilters && ( diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 8c95761f..2d7353b0 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -218,6 +218,7 @@ const CONTRIBUTOR_ENTRIES = [ 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)', + 'Favorites: virtualized songs tracklist for large starred collections (PR #805)', ], }, {