diff --git a/CHANGELOG.md b/CHANGELOG.md index 79cf5b3b..bdcbd520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Queue — smoother scrolling for very long queues + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#857](https://github.com/Psychotoxical/psysonic/pull/857)** + +* The queue panel now renders only the rows in view, so very long queues (e.g. hours of Artist Radio) stay smooth instead of bogging down the interface. + + + ## Fixed ### In-page browse — virtual scroll and cover-art priority diff --git a/src/components/QueuePanel.test.tsx b/src/components/QueuePanel.test.tsx index 95af954d..03c62383 100644 --- a/src/components/QueuePanel.test.tsx +++ b/src/components/QueuePanel.test.tsx @@ -62,6 +62,20 @@ beforeEach(() => { }); describe('QueuePanel — render surface', () => { + // jsdom has no layout, so the virtualized QueueList sees a 0px viewport and + // renders nothing. @tanstack/virtual-core measures via offsetHeight, so give + // the scroll viewport a height and rows a fixed height — then the virtualizer + // produces rows the way it does in the browser. + let offsetSpy: ReturnType; + beforeEach(() => { + offsetSpy = vi + .spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.classList.contains('queue-list') ? 600 : 52; + }); + }); + afterEach(() => offsetSpy.mockRestore()); + it('renders an empty-queue affordance when the queue is empty', () => { const { container } = renderWithProviders(); expect(container.querySelector('.queue-panel')).not.toBeNull(); diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index deef8733..862e61c9 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -160,7 +160,6 @@ function QueuePanelHostOrSolo() { queue, queueIndex, currentTrack, - activeTab, queueListRef, suppressNextAutoScrollRef, }); diff --git a/src/components/queuePanel/QueueList.tsx b/src/components/queuePanel/QueueList.tsx index 024a1645..365bd907 100644 --- a/src/components/queuePanel/QueueList.tsx +++ b/src/components/queuePanel/QueueList.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { Play } from 'lucide-react'; import type { TFunction } from 'i18next'; import OverlayScrollArea from '../OverlayScrollArea'; @@ -35,12 +36,52 @@ export function QueueList({ suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget, startDrag, orbitAttributionLabel, luckyRolling, t, }: Props) { + // Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows). + // Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have + // variable height (radio/auto dividers, lucky-mix loader) so we measure them. + const rowVirtualizer = useVirtualizer({ + count: queue.length, + getScrollElement: () => queueListRef.current, + estimateSize: () => 52, + overscan: 10, + getItemKey: i => `${queue[i].id}:${i}`, + // Start with a sensible viewport height so rows render before the + // ResizeObserver reports the real size (SSR / jsdom, where the observer + // never fires). The real height overrides this on first measure. + initialRect: { width: 0, height: 600 }, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + + // Auto-scroll the upcoming track into view on track change. Replaces the + // scrollIntoView path in useQueueAutoScroll, which relied on every row being + // in the DOM. Honours the suppression flag (row click / undo restore). + useEffect(() => { + if (suppressNextAutoScrollRef.current) { + suppressNextAutoScrollRef.current = false; + return; + } + if (activeTab !== 'queue' || queueIndex < 0 || queue.length === 0) return; + const target = Math.min(queueIndex + 1, queue.length - 1); + // First bring the target row into the rendered range via the virtualizer + // (estimate-based, so it can land a few px off). Then snap to the real DOM + // element so it sits flush at the top — no sliver of the now-playing row, + // which already shows in the header. scrollIntoView is exact (no estimate). + rowVirtualizer.scrollToIndex(target, { align: 'start' }); + const id = requestAnimationFrame(() => { + const el = queueListRef.current?.querySelector(`[data-queue-idx="${target}"]`); + el?.scrollIntoView({ block: 'start', behavior: 'instant' }); + }); + return () => cancelAnimationFrame(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queueIndex, activeTab]); + return ( @@ -49,8 +90,10 @@ export function QueueList({ {t('queue.emptyQueue')} ) : ( - <> - {queue.map((track, idx) => { +
+ {virtualItems.map(vi => { + const idx = vi.index; + const track = queue[idx]; const isPlaying = idx === queueIndex; const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); @@ -67,7 +110,12 @@ export function QueueList({ } return ( - +
{isFirstRadioAdded && (
{t('queue.radioAdded')} @@ -154,10 +202,10 @@ export function QueueList({
)} - +
); })} - +
)}
); diff --git a/src/hooks/useQueueAutoScroll.ts b/src/hooks/useQueueAutoScroll.ts index 57d7c4dd..6a6931e7 100644 --- a/src/hooks/useQueueAutoScroll.ts +++ b/src/hooks/useQueueAutoScroll.ts @@ -1,4 +1,4 @@ -import React, { useEffect, useLayoutEffect } from 'react'; +import React, { useLayoutEffect } from 'react'; import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo'; import type { Track } from '../store/playerStoreTypes'; @@ -6,16 +6,16 @@ interface Args { queue: Track[]; queueIndex: number; currentTrack: Track | null; - activeTab: string; queueListRef: React.RefObject; suppressNextAutoScrollRef: React.MutableRefObject; } -/** Queue auto-scroll: keeps the next track in view as playback progresses, - * publishes the list's scrollTop to the undo store, and restores any pending - * scrollTop snapshot (set when an undo restores a prior queue state). */ +/** Queue scroll-position bridge for undo: publishes the list's scrollTop to the + * undo store and restores any pending scrollTop snapshot (set when an undo + * restores a prior queue state). The "scroll the next track into view" path + * lives in `QueueList` now that the list is virtualized (scrollToIndex). */ export function useQueueAutoScroll({ - queue, queueIndex, currentTrack, activeTab, queueListRef, suppressNextAutoScrollRef, + queue, queueIndex, currentTrack, queueListRef, suppressNextAutoScrollRef, }: Args) { useLayoutEffect(() => { registerQueueListScrollTopReader(() => queueListRef.current?.scrollTop); @@ -31,21 +31,4 @@ export function useQueueAutoScroll({ el.scrollTop = top; el.dispatchEvent(new Event('scroll', { bubbles: false })); }, [queue, queueIndex, currentTrack?.id, queueListRef, suppressNextAutoScrollRef]); - - useEffect(function queueAutoScroll() { - if (suppressNextAutoScrollRef.current) { - suppressNextAutoScrollRef.current = false; - return; - } - if (!queueListRef.current || queueIndex < 0) return; - if (activeTab !== 'queue') return; - const songs = queueListRef.current!.querySelectorAll('[data-queue-idx]'); - const nextSong = songs[queueIndex + 1]; - if (!nextSong) return; - nextSong.scrollIntoView({ block: 'start', behavior: 'instant' }); - requestAnimationFrame(() => { - queueListRef.current?.dispatchEvent(new Event('scroll', { bubbles: false })); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentTrack, activeTab]); }