mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(queue): resolve queue rows on scroll so off-window items stop showing '…' (#1236)
This commit is contained in:
@@ -71,6 +71,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
* Adding tracks to a playlist no longer fails past ~341 songs — writes are sent to the server in batches instead of one oversized request, so playlists of any size build correctly.
|
* Adding tracks to a playlist no longer fails past ~341 songs — writes are sent to the server in batches instead of one oversized request, so playlists of any size build correctly.
|
||||||
* Adding and merging into large playlists is faster: playlist membership is cached in memory for de-duplication instead of re-fetching the whole playlist on every add.
|
* Adding and merging into large playlists is faster: playlist membership is cached in memory for de-duplication instead of re-fetching the whole playlist on every add.
|
||||||
|
|
||||||
|
### Queue — rows no longer stuck showing "…"
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1236](https://github.com/Psychotoxical/psysonic/pull/1236)**
|
||||||
|
|
||||||
|
* Queue rows that were far from the currently playing track (e.g. after starting a large playlist from the middle, or scrolling the queue) no longer stay stuck on a "…" placeholder — the queue now loads track details for whatever you scroll to, in the desktop queue panel, the mobile queue drawer, and the fullscreen "up next" overlay.
|
||||||
|
|
||||||
|
|
||||||
## [1.49.0] - 2026-06-29
|
## [1.49.0] - 2026-06-29
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Artists browse — album vs track credit mode toggle, starred favorites in both modes, persisted credit mode, SQL letter-bucket filter (PR #1232)',
|
'Artists browse — album vs track credit mode toggle, starred favorites in both modes, persisted credit mode, SQL letter-bucket filter (PR #1232)',
|
||||||
'Connection — ignore spurious WebKitGTK navigator.onLine offline hint on desktop; confirm via server probe; flush pending favorite/rating sync on real reachability (report: mikmik on Psysonic Discord, PR #1234)',
|
'Connection — ignore spurious WebKitGTK navigator.onLine offline hint on desktop; confirm via server probe; flush pending favorite/rating sync on real reachability (report: mikmik on Psysonic Discord, PR #1234)',
|
||||||
'Playlists — batch playlist writes past the GET URL limit (add >341 tracks), in-memory membership cache for fast dedup, offline↔playlist layering detangle (PR #1235)',
|
'Playlists — batch playlist writes past the GET URL limit (add >341 tracks), in-memory membership cache for fast dedup, offline↔playlist layering detangle (PR #1235)',
|
||||||
|
'Queue — resolve thin-state rows off the visible range so off-window items stop rendering as "…" placeholders (desktop panel, mobile drawer, fullscreen up-next) (PR #1236)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useMemo, useSyncExternalStore } from 'react';
|
import { memo, useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||||
@@ -7,6 +7,7 @@ import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
|||||||
import {
|
import {
|
||||||
getQueueResolverVersion,
|
getQueueResolverVersion,
|
||||||
subscribeQueueResolver,
|
subscribeQueueResolver,
|
||||||
|
resolveBatch,
|
||||||
} from '@/features/playback/store/queueTrackResolver';
|
} from '@/features/playback/store/queueTrackResolver';
|
||||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||||
|
|
||||||
@@ -37,6 +38,19 @@ export const FsQueueModal = memo(function FsQueueModal({ onClose }: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [queueItems, queueIndex, version]);
|
}, [queueItems, queueIndex, version]);
|
||||||
|
|
||||||
|
// This overlay renders the whole "up next" list (not virtualized), but the
|
||||||
|
// resolver bridge only warms a window around the playing index — so rows past
|
||||||
|
// that window would show the '…' placeholder. Resolve every upcoming ref shown
|
||||||
|
// here; resolveBatch dedups against cache/in-flight.
|
||||||
|
useEffect(() => {
|
||||||
|
const refs = [];
|
||||||
|
for (let i = queueIndex + 1; i < queueItems.length; i++) {
|
||||||
|
const ref = queueItems[i];
|
||||||
|
if (ref) refs.push({ serverId: ref.serverId, trackId: ref.trackId });
|
||||||
|
}
|
||||||
|
if (refs.length > 0) void resolveBatch(refs);
|
||||||
|
}, [queueItems, queueIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fsq-backdrop"
|
className="fsq-backdrop"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
|||||||
import {
|
import {
|
||||||
getQueueResolverVersion,
|
getQueueResolverVersion,
|
||||||
subscribeQueueResolver,
|
subscribeQueueResolver,
|
||||||
|
resolveVisibleRange,
|
||||||
} from '@/features/playback/store/queueTrackResolver';
|
} from '@/features/playback/store/queueTrackResolver';
|
||||||
import { LyricsPane } from '@/features/lyrics';
|
import { LyricsPane } from '@/features/lyrics';
|
||||||
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
|
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
|
||||||
@@ -111,6 +112,18 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
|
|||||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
const totalSize = rowVirtualizer.getTotalSize();
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
|
// Resolve the rows actually in view (queue thin-state). The resolver bridge only
|
||||||
|
// warms a window around the playing index, so scrolling the drawer would leave
|
||||||
|
// far-off rows stuck on the '…' placeholder. Drive resolution off the visible
|
||||||
|
// range; resolveVisibleRange dedups against cache/in-flight.
|
||||||
|
const firstVisible = virtualItems.length > 0 ? virtualItems[0]!.index : 0;
|
||||||
|
const lastVisible = virtualItems.length > 0 ? virtualItems[virtualItems.length - 1]!.index : 0;
|
||||||
|
useEffect(() => {
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
resolveVisibleRange(queue, firstVisible, lastVisible);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [firstVisible, lastVisible, queue.length]);
|
||||||
|
|
||||||
// Scroll the active track into view on open. Rows are uniform height, so the
|
// Scroll the active track into view on open. Rows are uniform height, so the
|
||||||
// virtualizer's estimate lands the centred index accurately.
|
// virtualizer's estimate lands the centred index accurately.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
|||||||
import {
|
import {
|
||||||
getQueueResolverVersion,
|
getQueueResolverVersion,
|
||||||
subscribeQueueResolver,
|
subscribeQueueResolver,
|
||||||
|
resolveBatch,
|
||||||
} from '@/features/playback/store/queueTrackResolver';
|
} from '@/features/playback/store/queueTrackResolver';
|
||||||
|
import { collectQueueResolveRefs } from '@/features/queue/utils/collectQueueResolveRefs';
|
||||||
import { findQueueItemRefIndex } from '@/features/playback/utils/playback/queueIdentity';
|
import { findQueueItemRefIndex } from '@/features/playback/utils/playback/queueIdentity';
|
||||||
import type { TimelineDisplayRow } from '@/features/playback/utils/buildTimelineDisplayRows';
|
import type { TimelineDisplayRow } from '@/features/playback/utils/buildTimelineDisplayRows';
|
||||||
import { findTimelineScrollLocalIndex } from '@/features/playback/utils/buildTimelineDisplayRows';
|
import { findTimelineScrollLocalIndex } from '@/features/playback/utils/buildTimelineDisplayRows';
|
||||||
@@ -77,6 +79,22 @@ export function QueueList({
|
|||||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
const totalSize = rowVirtualizer.getTotalSize();
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
|
// Resolve the rows the user is actually looking at (queue thin-state). The
|
||||||
|
// queueResolverBridge only warms a window around the *playing* index; scrolling
|
||||||
|
// the queue independently would otherwise leave rows stuck on the '…'
|
||||||
|
// placeholder (cache miss / evicted from the bounded LRU). Drive resolution off
|
||||||
|
// the virtualizer's visible range so any scrolled-to row fetches on demand.
|
||||||
|
const firstVisible = virtualItems.length > 0 ? virtualItems[0]!.index : 0;
|
||||||
|
const lastVisible = virtualItems.length > 0 ? virtualItems[virtualItems.length - 1]!.index : 0;
|
||||||
|
useEffect(() => {
|
||||||
|
if (rowCount === 0) return;
|
||||||
|
const refs = collectQueueResolveRefs({ usingTimeline, timelineRows, queue, firstVisible, lastVisible });
|
||||||
|
if (refs.length > 0) void resolveBatch(refs);
|
||||||
|
// Keyed on the visible range + mode; the resolver dedups against cache/in-flight,
|
||||||
|
// so re-running on scroll is cheap. Queue-content changes are covered by the bridge.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [firstVisible, lastVisible, rowCount, usingTimeline]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (suppressNextAutoScrollRef.current) {
|
if (suppressNextAutoScrollRef.current) {
|
||||||
suppressNextAutoScrollRef.current = false;
|
suppressNextAutoScrollRef.current = false;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||||
|
import type { TimelineDisplayRow } from '@/features/playback/utils/buildTimelineDisplayRows';
|
||||||
|
import { collectQueueResolveRefs } from '@/features/queue/utils/collectQueueResolveRefs';
|
||||||
|
|
||||||
|
const ref = (i: number): QueueItemRef => ({ serverId: 's1', trackId: `t${i}` });
|
||||||
|
|
||||||
|
describe('collectQueueResolveRefs', () => {
|
||||||
|
it('resolves the visible range plus the prefetch window for the plain queue', () => {
|
||||||
|
const queue = Array.from({ length: 1000 }, (_, i) => ref(i));
|
||||||
|
const refs = collectQueueResolveRefs({
|
||||||
|
usingTimeline: false,
|
||||||
|
timelineRows: undefined,
|
||||||
|
queue,
|
||||||
|
firstVisible: 400,
|
||||||
|
lastVisible: 410,
|
||||||
|
});
|
||||||
|
// window = [400-50, 410+200] = [350, 610]
|
||||||
|
expect(refs[0]!.trackId).toBe('t350');
|
||||||
|
expect(refs[refs.length - 1]!.trackId).toBe('t610');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the window to the queue bounds', () => {
|
||||||
|
const queue = Array.from({ length: 20 }, (_, i) => ref(i));
|
||||||
|
const refs = collectQueueResolveRefs({
|
||||||
|
usingTimeline: false,
|
||||||
|
timelineRows: undefined,
|
||||||
|
queue,
|
||||||
|
firstVisible: 0,
|
||||||
|
lastVisible: 5,
|
||||||
|
});
|
||||||
|
expect(refs).toHaveLength(20);
|
||||||
|
expect(refs[0]!.trackId).toBe('t0');
|
||||||
|
expect(refs[19]!.trackId).toBe('t19');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for an empty queue', () => {
|
||||||
|
expect(
|
||||||
|
collectQueueResolveRefs({
|
||||||
|
usingTimeline: false,
|
||||||
|
timelineRows: undefined,
|
||||||
|
queue: [],
|
||||||
|
firstVisible: 0,
|
||||||
|
lastVisible: 0,
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips divider rows and collects track refs in timeline mode', () => {
|
||||||
|
const rows: TimelineDisplayRow[] = [
|
||||||
|
{ kind: 'divider', labelKey: 'queue.history', localIndex: 0, key: 'd1' },
|
||||||
|
{ kind: 'history', ref: { serverId: 's1', trackId: 'h1', playedAtMs: 1 }, localIndex: 1, key: 'h1' },
|
||||||
|
{ kind: 'current', ref: ref(0), queueIndex: 0, localIndex: 2, key: 'c' },
|
||||||
|
{ kind: 'divider', labelKey: 'queue.upNext', localIndex: 3, key: 'd2' },
|
||||||
|
{ kind: 'upcoming', ref: ref(1), queueIndex: 1, localIndex: 4, key: 'u1' },
|
||||||
|
{ kind: 'upcoming', ref: ref(2), queueIndex: 2, localIndex: 5, key: 'u2' },
|
||||||
|
];
|
||||||
|
const refs = collectQueueResolveRefs({
|
||||||
|
usingTimeline: true,
|
||||||
|
timelineRows: rows,
|
||||||
|
queue: [],
|
||||||
|
firstVisible: 0,
|
||||||
|
lastVisible: 5,
|
||||||
|
});
|
||||||
|
expect(refs.map(r => r.trackId)).toEqual(['h1', 't0', 't1', 't2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||||
|
|
||||||
|
/** Prefetch window around the visible range (mirrors the resolver contract). */
|
||||||
|
const PREFETCH_BACK = 50;
|
||||||
|
const PREFETCH_AHEAD = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal structural shape of a timeline row for resolution purposes. Kept local
|
||||||
|
* (not imported from the playback feature) so this helper stays inside the queue
|
||||||
|
* feature's dependency floor; `TimelineDisplayRow` is structurally assignable.
|
||||||
|
*/
|
||||||
|
export interface ResolveTimelineRow {
|
||||||
|
kind: 'history' | 'divider' | 'current' | 'upcoming';
|
||||||
|
ref?: { serverId: string; trackId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect the `QueueItemRef`s to resolve for the queue list's currently visible
|
||||||
|
* range plus a prefetch window (queue thin-state). Works for both the plain
|
||||||
|
* `queue` display and the `timeline` display (which interleaves history/current/
|
||||||
|
* upcoming rows and dividers). Dividers carry no track, so they are skipped.
|
||||||
|
*/
|
||||||
|
export function collectQueueResolveRefs(args: {
|
||||||
|
usingTimeline: boolean;
|
||||||
|
timelineRows: readonly ResolveTimelineRow[] | undefined;
|
||||||
|
queue: readonly QueueItemRef[];
|
||||||
|
firstVisible: number;
|
||||||
|
lastVisible: number;
|
||||||
|
}): QueueItemRef[] {
|
||||||
|
const { usingTimeline, timelineRows, queue, firstVisible, lastVisible } = args;
|
||||||
|
|
||||||
|
if (usingTimeline && timelineRows) {
|
||||||
|
const start = Math.max(0, firstVisible - PREFETCH_BACK);
|
||||||
|
const end = Math.min(timelineRows.length, lastVisible + PREFETCH_AHEAD + 1);
|
||||||
|
const refs: QueueItemRef[] = [];
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const row = timelineRows[i];
|
||||||
|
if (row && row.kind !== 'divider' && row.ref) {
|
||||||
|
refs.push({ serverId: row.ref.serverId, trackId: row.ref.trackId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, firstVisible - PREFETCH_BACK);
|
||||||
|
const end = Math.min(queue.length, lastVisible + PREFETCH_AHEAD + 1);
|
||||||
|
if (end <= start) return [];
|
||||||
|
return queue.slice(start, end).map(r => ({ serverId: r.serverId, trackId: r.trackId }));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user