mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
perf(queue): virtualize the queue list (#857)
* perf(queue): virtualize the queue list Render the QueuePanel queue through @tanstack/react-virtual so the DOM stays O(visible rows) instead of O(queue length) — a 10k+ Artist Radio queue no longer creates tens of thousands of DOM nodes. Row logic (reorder DnD, radio/ auto dividers, lucky-mix loader, context menu) is unchanged, just wrapped in the virtual rows. Auto-scroll to the next track moves out of useQueueAutoScroll (which relied on every row being in the DOM) to a virtualizer scroll plus an exact scrollIntoView on the real target row. Phase 0 of the queue thin-state plan; no store/persist changes. * docs(changelog): virtualized queue list (#857)
This commit is contained in:
committed by
GitHub
parent
10c3d9a3ce
commit
06213cb5d8
@@ -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
|
## Fixed
|
||||||
|
|
||||||
### In-page browse — virtual scroll and cover-art priority
|
### In-page browse — virtual scroll and cover-art priority
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('QueuePanel — render surface', () => {
|
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<typeof vi.spyOn>;
|
||||||
|
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', () => {
|
it('renders an empty-queue affordance when the queue is empty', () => {
|
||||||
const { container } = renderWithProviders(<QueuePanel />);
|
const { container } = renderWithProviders(<QueuePanel />);
|
||||||
expect(container.querySelector('.queue-panel')).not.toBeNull();
|
expect(container.querySelector('.queue-panel')).not.toBeNull();
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ function QueuePanelHostOrSolo() {
|
|||||||
queue,
|
queue,
|
||||||
queueIndex,
|
queueIndex,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
activeTab,
|
|
||||||
queueListRef,
|
queueListRef,
|
||||||
suppressNextAutoScrollRef,
|
suppressNextAutoScrollRef,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { Play } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import OverlayScrollArea from '../OverlayScrollArea';
|
import OverlayScrollArea from '../OverlayScrollArea';
|
||||||
@@ -35,12 +36,52 @@ export function QueueList({
|
|||||||
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
|
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
|
||||||
startDrag, orbitAttributionLabel, luckyRolling, t,
|
startDrag, orbitAttributionLabel, luckyRolling, t,
|
||||||
}: Props) {
|
}: 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<HTMLElement>(`[data-queue-idx="${target}"]`);
|
||||||
|
el?.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [queueIndex, activeTab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OverlayScrollArea
|
<OverlayScrollArea
|
||||||
viewportRef={queueListRef}
|
viewportRef={queueListRef}
|
||||||
className="queue-list-wrap"
|
className="queue-list-wrap"
|
||||||
viewportClassName="queue-list"
|
viewportClassName="queue-list"
|
||||||
measureDeps={[activeTab, queue.length]}
|
measureDeps={[activeTab, queue.length, totalSize]}
|
||||||
railInset="panel"
|
railInset="panel"
|
||||||
viewportScrollBehaviorAuto={isQueueDrag}
|
viewportScrollBehaviorAuto={isQueueDrag}
|
||||||
>
|
>
|
||||||
@@ -49,8 +90,10 @@ export function QueueList({
|
|||||||
{t('queue.emptyQueue')}
|
{t('queue.emptyQueue')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||||
{queue.map((track, idx) => {
|
{virtualItems.map(vi => {
|
||||||
|
const idx = vi.index;
|
||||||
|
const track = queue[idx];
|
||||||
const isPlaying = idx === queueIndex;
|
const isPlaying = idx === queueIndex;
|
||||||
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||||
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||||
@@ -67,7 +110,12 @@ export function QueueList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`${track.id}-${idx}`}>
|
<div
|
||||||
|
key={vi.key}
|
||||||
|
data-index={idx}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||||
|
>
|
||||||
{isFirstRadioAdded && (
|
{isFirstRadioAdded && (
|
||||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
|
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
|
||||||
@@ -154,10 +202,10 @@ export function QueueList({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</OverlayScrollArea>
|
</OverlayScrollArea>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useLayoutEffect } from 'react';
|
import React, { useLayoutEffect } from 'react';
|
||||||
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
|
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
|
|
||||||
@@ -6,16 +6,16 @@ interface Args {
|
|||||||
queue: Track[];
|
queue: Track[];
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
activeTab: string;
|
|
||||||
queueListRef: React.RefObject<HTMLDivElement | null>;
|
queueListRef: React.RefObject<HTMLDivElement | null>;
|
||||||
suppressNextAutoScrollRef: React.MutableRefObject<boolean>;
|
suppressNextAutoScrollRef: React.MutableRefObject<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queue auto-scroll: keeps the next track in view as playback progresses,
|
/** Queue scroll-position bridge for undo: publishes the list's scrollTop to the
|
||||||
* publishes the list's scrollTop to the undo store, and restores any pending
|
* undo store and restores any pending scrollTop snapshot (set when an undo
|
||||||
* scrollTop snapshot (set when an undo restores a prior queue state). */
|
* 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({
|
export function useQueueAutoScroll({
|
||||||
queue, queueIndex, currentTrack, activeTab, queueListRef, suppressNextAutoScrollRef,
|
queue, queueIndex, currentTrack, queueListRef, suppressNextAutoScrollRef,
|
||||||
}: Args) {
|
}: Args) {
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
registerQueueListScrollTopReader(() => queueListRef.current?.scrollTop);
|
registerQueueListScrollTopReader(() => queueListRef.current?.scrollTop);
|
||||||
@@ -31,21 +31,4 @@ export function useQueueAutoScroll({
|
|||||||
el.scrollTop = top;
|
el.scrollTop = top;
|
||||||
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||||
}, [queue, queueIndex, currentTrack?.id, queueListRef, suppressNextAutoScrollRef]);
|
}, [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<HTMLElement>('[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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user