diff --git a/CHANGELOG.md b/CHANGELOG.md index 231f3333..173920c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 976f4a55..53b916cb 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', '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)', + '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)', ], }, { diff --git a/src/features/fullscreenPlayer/components/FsQueueModal.tsx b/src/features/fullscreenPlayer/components/FsQueueModal.tsx index 81a00870..d948ff96 100644 --- a/src/features/fullscreenPlayer/components/FsQueueModal.tsx +++ b/src/features/fullscreenPlayer/components/FsQueueModal.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useSyncExternalStore } from 'react'; +import { memo, useEffect, useMemo, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import { X } from 'lucide-react'; import { usePlayerStore } from '@/features/playback/store/playerStore'; @@ -7,6 +7,7 @@ import { resolveQueueTrack } from '@/features/playback/store/queueTrackView'; import { getQueueResolverVersion, subscribeQueueResolver, + resolveBatch, } from '@/features/playback/store/queueTrackResolver'; 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 }, [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 (
void }) { const virtualItems = rowVirtualizer.getVirtualItems(); 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 // virtualizer's estimate lands the centred index accurately. useEffect(() => { diff --git a/src/features/queue/components/QueueList.tsx b/src/features/queue/components/QueueList.tsx index 88be06c2..62cd0035 100644 --- a/src/features/queue/components/QueueList.tsx +++ b/src/features/queue/components/QueueList.tsx @@ -13,7 +13,9 @@ import { resolveQueueTrack } from '@/features/playback/store/queueTrackView'; import { getQueueResolverVersion, subscribeQueueResolver, + resolveBatch, } from '@/features/playback/store/queueTrackResolver'; +import { collectQueueResolveRefs } from '@/features/queue/utils/collectQueueResolveRefs'; import { findQueueItemRefIndex } from '@/features/playback/utils/playback/queueIdentity'; import type { TimelineDisplayRow } from '@/features/playback/utils/buildTimelineDisplayRows'; import { findTimelineScrollLocalIndex } from '@/features/playback/utils/buildTimelineDisplayRows'; @@ -77,6 +79,22 @@ export function QueueList({ const virtualItems = rowVirtualizer.getVirtualItems(); 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(() => { if (suppressNextAutoScrollRef.current) { suppressNextAutoScrollRef.current = false; diff --git a/src/features/queue/utils/collectQueueResolveRefs.test.ts b/src/features/queue/utils/collectQueueResolveRefs.test.ts new file mode 100644 index 00000000..43bce902 --- /dev/null +++ b/src/features/queue/utils/collectQueueResolveRefs.test.ts @@ -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']); + }); +}); diff --git a/src/features/queue/utils/collectQueueResolveRefs.ts b/src/features/queue/utils/collectQueueResolveRefs.ts new file mode 100644 index 00000000..9ae46ee3 --- /dev/null +++ b/src/features/queue/utils/collectQueueResolveRefs.ts @@ -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 })); +}