fix(queue): stop re-walking the whole queue on every track change

QueueHeader aggregated total/future duration in a useMemo keyed on queueIndex,
so every skip ran a synchronous O(n) pass over the entire queue (resolveQueueTrack
per item). On very large queues this blocked the main thread for seconds — the
UI froze on skip and on the device-switch playTrack fallback (#1072; the freeze
half of #1090). QueuePanel is always mounted, so it hit even with the queue
collapsed.

Build a cumulative-duration prefix keyed on queue/resolver-version only; the
future-tracks total is now an O(1) lookup per skip. Display output unchanged.

(cherry picked from commit 7e91a5b2a1)
This commit is contained in:
Psychotoxical
2026-06-15 22:11:56 +02:00
committed by cucadmuh
parent 15fb0f6c56
commit e563749ace
+20 -12
View File
@@ -44,23 +44,31 @@ export function QueueHeader({
// H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide) // H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide)
// bumps `version` dozens of times in one frame; useDeferredValue coalesces // bumps `version` dozens of times in one frame; useDeferredValue coalesces
// the burst into a single low-priority commit so long queues do not block // the burst into a single low-priority commit so long queues do not block
// the main thread on every cache tick. The aggregation itself is a single // the main thread on every cache tick.
// pass — one loop produces both totals so a 50k-track queue costs one walk, //
// not two. // The O(n) walk is keyed on `queue`/`deferredVersion` only — NOT `queueIndex`.
// A skip moves only `queueIndex`, so it must not re-walk the whole queue: that
// synchronous O(n) pass on every track change froze the UI for seconds on very
// large queues (#1072, and the device-switch fallback in #1090). Instead we
// build a cumulative-duration prefix (`cumSecs[i]` = summed duration of tracks
// [0, i)) once per queue/cache change, then derive the future total as an O(1)
// lookup below. A 50k-track queue costs one walk per queue/cache change, zero
// per track change.
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion); const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
const deferredVersion = useDeferredValue(version); const deferredVersion = useDeferredValue(version);
const { totalSecs, futureTracksDuration } = useMemo(() => { const { totalSecs, cumSecs } = useMemo(() => {
if (queue.length === 0) return { totalSecs: 0, futureTracksDuration: 0 }; const cum = new Float64Array(queue.length + 1);
let total = 0;
let future = 0;
for (let i = 0; i < queue.length; i += 1) { for (let i = 0; i < queue.length; i += 1) {
const dur = resolveQueueTrack(queue[i]).duration || 0; cum[i + 1] = cum[i] + (resolveQueueTrack(queue[i]).duration || 0);
total += dur;
if (i > queueIndex) future += dur;
} }
return { totalSecs: total, futureTracksDuration: future }; return { totalSecs: cum[queue.length], cumSecs: cum };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue, queueIndex, deferredVersion]); }, [queue, deferredVersion]);
// Tracks strictly after the current index — O(1) per skip.
const futureTracksDuration = Math.max(
0,
totalSecs - cumSecs[Math.min(queueIndex + 1, queue.length)],
);
const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0; const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0;
const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration); const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration);