fix queue items disappearing on fast scrolling

This commit is contained in:
Boof2015
2026-07-28 13:44:07 -04:00
parent ce4e173231
commit 8d78278852
7 changed files with 359 additions and 64 deletions
+176 -55
View File
@@ -21,7 +21,11 @@ import BottomSheet, {
type BottomSheetHandleProps,
useBottomSheetScrollableCreator
} from '@gorhom/bottom-sheet';
import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
import {
FlashList,
type ListRenderItemInfo,
type ViewToken,
} from '@shopify/flash-list';
import {
Gesture,
GestureDetector,
@@ -73,11 +77,19 @@ import {
type QueueIndexByKey,
} from './queueActions';
import {
QUEUE_RENDER_DISTANCE,
QUEUE_ROW_HEIGHT,
queuePreviewRowCount,
queueRenderDistance,
} from './queuePerformance';
import { QueueSheetHandle } from './QueueSheetHandle';
import {
VIRTUAL_QUEUE_PAGE_SIZE,
isCurrentVirtualQueueRequest,
mergeVirtualQueueTracks,
nextVirtualQueuePageStart,
seedVirtualQueueTracks,
shouldPrefetchVirtualQueue,
} from './virtualQueuePaging';
const ART = 42;
const EMPTY_KEY_SET = new Set<string>();
@@ -194,6 +206,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
const [listPainted, setListPainted] = useState(false);
const onListLoad = useCallback(() => setListPainted(true), []);
const previewCount = queuePreviewRowCount(windowHeight);
const renderDistance = queueRenderDistance(windowHeight);
// Bottom padding clears the gesture-nav inset so the last row is fully
// scrollable into view at the 100% snap.
const listContentStyle = useMemo(
@@ -211,69 +224,176 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
() => (activeIndex >= 0 ? tracks.slice(activeIndex + 1) : tracks),
[tracks, activeIndex]
);
const rollingUpcomingTracksRef = useRef(rollingUpcomingTracks);
rollingUpcomingTracksRef.current = rollingUpcomingTracks;
const virtualState = getVirtualQueueState();
const virtualMode = virtualState !== null;
const virtualActivePosition = virtualState?.activePosition ?? -1;
const [virtualTracks, setVirtualTracks] = useState<RntpTrack[]>([]);
const virtualTracksRef = useRef<RntpTrack[]>([]);
const virtualLoadGeneration = useRef(0);
const virtualLoading = useRef(false);
const loadVirtualPage = useCallback(async (reset: boolean) => {
const state = getVirtualQueueState();
if (!state || (!reset && virtualLoading.current)) return;
const generation = reset ? ++virtualLoadGeneration.current : virtualLoadGeneration.current;
const existing = reset ? [] : virtualTracksRef.current;
const lastPosition = existing.length > 0
? existing[existing.length - 1].astraQueuePosition
: state.activePosition;
const start = typeof lastPosition === 'number'
? lastPosition + 1
: state.activePosition + 1;
if (start >= state.totalCount) {
if (reset) {
virtualLoading.current = false;
virtualTracksRef.current = [];
setVirtualTracks([]);
}
return;
}
virtualLoading.current = true;
try {
const page = await getVirtualQueuePage(start, 100);
if (
!page ||
generation !== virtualLoadGeneration.current ||
getVirtualQueueState()?.sessionId !== state.sessionId
) return;
const existingPositions = new Set(
existing.map((track) => track.astraQueuePosition).filter(
(position): position is number => typeof position === 'number'
const [virtualTracks, setVirtualTracks] = useState<RntpTrack[]>(() => (
virtualState
? seedVirtualQueueTracks(
rollingUpcomingTracks,
virtualState.activePosition,
virtualState.totalCount,
)
);
const incoming = page.items
.filter((item) => item.queuePosition >= start && !existingPositions.has(item.queuePosition))
.map((item) => item.track);
const next = reset ? incoming : [...existing, ...incoming];
// Keep no more than five tray pages in JS.
const bounded = next.slice(-500);
virtualTracksRef.current = bounded;
setVirtualTracks(bounded);
} finally {
if (generation === virtualLoadGeneration.current) virtualLoading.current = false;
: []
));
const virtualTracksRef = useRef<RntpTrack[]>(virtualTracks);
const virtualLoadGeneration = useRef(0);
const virtualLoadPromise = useRef<Promise<void> | null>(null);
const virtualLoadQueued = useRef(false);
const virtualRetryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const virtualPageLoaderRef = useRef<(reset: boolean) => Promise<void>>(async () => {});
const loadVirtualPage = useCallback((reset: boolean): Promise<void> => {
if (reset) {
virtualLoadGeneration.current += 1;
virtualLoadQueued.current = false;
if (virtualRetryTimer.current) {
clearTimeout(virtualRetryTimer.current);
virtualRetryTimer.current = null;
}
const state = getVirtualQueueState();
const seed = state
? seedVirtualQueueTracks(
rollingUpcomingTracksRef.current,
state.activePosition,
state.totalCount,
)
: [];
virtualTracksRef.current = seed;
setVirtualTracks(seed);
if (!state) return Promise.resolve();
if (virtualLoadPromise.current) {
virtualLoadQueued.current = true;
return virtualLoadPromise.current;
}
}
if (virtualLoadPromise.current) {
virtualLoadQueued.current = true;
return virtualLoadPromise.current;
}
const state = getVirtualQueueState();
if (!state) return Promise.resolve();
const start = nextVirtualQueuePageStart(
virtualTracksRef.current,
state.activePosition,
state.totalCount,
);
if (start === null) return Promise.resolve();
const generation = virtualLoadGeneration.current;
const request = (async () => {
let page: Awaited<ReturnType<typeof getVirtualQueuePage>> = null;
let failed = false;
for (let attempt = 0; attempt < 2; attempt += 1) {
if (!isCurrentVirtualQueueRequest(
generation,
virtualLoadGeneration.current,
state.sessionId,
getVirtualQueueState()?.sessionId,
)) return;
try {
page = await getVirtualQueuePage(start, VIRTUAL_QUEUE_PAGE_SIZE);
failed = false;
break;
} catch {
failed = true;
if (attempt === 0) {
await new Promise<void>((resolve) => setTimeout(resolve, 120));
}
}
}
const currentState = getVirtualQueueState();
if (!isCurrentVirtualQueueRequest(
generation,
virtualLoadGeneration.current,
state.sessionId,
currentState?.sessionId,
)) return;
if (!currentState) return;
if (!page) {
if (failed && !virtualRetryTimer.current) {
virtualRetryTimer.current = setTimeout(() => {
virtualRetryTimer.current = null;
void virtualPageLoaderRef.current(false);
}, 400);
}
return;
}
if (virtualRetryTimer.current) {
clearTimeout(virtualRetryTimer.current);
virtualRetryTimer.current = null;
}
const incoming = page.items
.filter((item) => (
item.queuePosition >= start &&
item.queuePosition < currentState.totalCount
))
.map((item) => item.track);
const next = mergeVirtualQueueTracks(virtualTracksRef.current, incoming);
if (
next.length === virtualTracksRef.current.length &&
next.every((track, index) => track === virtualTracksRef.current[index])
) return;
virtualTracksRef.current = next;
setVirtualTracks(next);
})();
virtualLoadPromise.current = request;
void request.finally(() => {
if (virtualLoadPromise.current !== request) return;
virtualLoadPromise.current = null;
if (virtualLoadQueued.current) {
virtualLoadQueued.current = false;
void virtualPageLoaderRef.current(false);
}
});
return request;
}, []);
virtualPageLoaderRef.current = loadVirtualPage;
useEffect(() => {
if (!virtualMode) {
virtualLoadGeneration.current += 1;
virtualTracksRef.current = [];
setVirtualTracks([]);
return;
}
void loadVirtualPage(true);
}, [loadVirtualPage, virtualActivePosition, virtualMode, virtualState?.sessionId]);
useEffect(() => () => {
virtualLoadGeneration.current += 1;
if (virtualRetryTimer.current) clearTimeout(virtualRetryTimer.current);
}, []);
const onQueueViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken<QueueEntry>[] }) => {
const state = getVirtualQueueState();
if (!state) return;
const lastVisibleIndex = viewableItems.reduce(
(last, token) => Math.max(last, token.index ?? -1),
-1,
);
const hasMore = nextVirtualQueuePageStart(
virtualTracksRef.current,
state.activePosition,
state.totalCount,
) !== null;
if (shouldPrefetchVirtualQueue(
lastVisibleIndex,
virtualTracksRef.current.length,
hasMore,
)) {
void loadVirtualPage(false);
}
},
[loadVirtualPage],
);
const upcomingTracks = virtualMode ? virtualTracks : rollingUpcomingTracks;
const upcomingTotal = virtualState
? Math.max(0, virtualState.totalCount - virtualState.activePosition - 1)
@@ -882,12 +1002,13 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
data={entries}
scrollEnabled
keyExtractor={(item) => item.key}
drawDistance={QUEUE_RENDER_DISTANCE}
drawDistance={renderDistance}
maintainVisibleContentPosition={{ disabled: true }}
renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent}
renderItem={renderItem}
extraData={listExtraData}
onLoad={onListLoad}
onViewableItemsChanged={virtualMode ? onQueueViewableItemsChanged : undefined}
onEndReached={virtualMode ? () => void loadVirtualPage(false) : undefined}
onEndReachedThreshold={0.6}
contentContainerStyle={embedded
+10 -5
View File
@@ -1,15 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
QUEUE_RENDER_AHEAD_ROWS,
QUEUE_RENDER_DISTANCE,
QUEUE_RENDER_AHEAD_MIN_ROWS,
QUEUE_RENDER_WINDOW_MULTIPLIER,
QUEUE_ROW_HEIGHT,
queuePreviewRowCount,
queueRenderDistance,
} from './queuePerformance.ts';
test('queue render-ahead stays bounded to four rows', () => {
assert.equal(QUEUE_RENDER_AHEAD_ROWS, 4);
assert.equal(QUEUE_RENDER_DISTANCE, QUEUE_ROW_HEIGHT * 4);
test('queue render-ahead covers two windows with a twelve-row minimum', () => {
assert.equal(QUEUE_RENDER_AHEAD_MIN_ROWS, 12);
assert.equal(QUEUE_RENDER_WINDOW_MULTIPLIER, 2);
assert.equal(queueRenderDistance(780), 1560);
assert.equal(queueRenderDistance(320), QUEUE_ROW_HEIGHT * 12);
assert.equal(queueRenderDistance(0), QUEUE_ROW_HEIGHT * 12);
assert.equal(queueRenderDistance(Number.NaN), QUEUE_ROW_HEIGHT * 12);
});
test('initial queue preview covers the sheet viewport without duplicating a screen', () => {
+11 -2
View File
@@ -1,5 +1,6 @@
export const QUEUE_ROW_HEIGHT = 64;
export const QUEUE_RENDER_AHEAD_ROWS = 4;
export const QUEUE_RENDER_AHEAD_MIN_ROWS = 12;
export const QUEUE_RENDER_WINDOW_MULTIPLIER = 2;
const QUEUE_SHEET_INITIAL_FRACTION = 0.58;
const QUEUE_PREVIEW_NON_LIST_HEIGHT = 220;
@@ -21,4 +22,12 @@ export function queuePreviewRowCount(windowHeight: number): number {
);
}
export const QUEUE_RENDER_DISTANCE = QUEUE_ROW_HEIGHT * QUEUE_RENDER_AHEAD_ROWS;
export function queueRenderDistance(windowHeight: number): number {
const safeWindowHeight = Number.isFinite(windowHeight)
? Math.max(0, windowHeight)
: 0;
return Math.max(
QUEUE_ROW_HEIGHT * QUEUE_RENDER_AHEAD_MIN_ROWS,
Math.ceil(safeWindowHeight * QUEUE_RENDER_WINDOW_MULTIPLIER),
);
}
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
VIRTUAL_QUEUE_PAGE_SIZE,
VIRTUAL_QUEUE_PREFETCH_ROWS,
isCurrentVirtualQueueRequest,
mergeVirtualQueueTracks,
nextVirtualQueuePageStart,
seedVirtualQueueTracks,
shouldPrefetchVirtualQueue,
} from './virtualQueuePaging.ts';
interface TestTrack {
id: string;
astraQueuePosition: number;
}
function tracks(start: number, count: number): TestTrack[] {
return Array.from({ length: count }, (_, index) => ({
id: `track-${start + index}`,
astraQueuePosition: start + index,
}));
}
test('seeds the tray from the already-loaded playback window after the active row', () => {
assert.deepEqual(
seedVirtualQueueTracks(tracks(20, 8), 22, 27).map((track) => track.astraQueuePosition),
[23, 24, 25, 26],
);
});
test('merges sequential virtual pages without dropping earlier tray rows', () => {
const first = tracks(1, VIRTUAL_QUEUE_PAGE_SIZE);
const second = tracks(VIRTUAL_QUEUE_PAGE_SIZE + 1, VIRTUAL_QUEUE_PAGE_SIZE);
const third = tracks(VIRTUAL_QUEUE_PAGE_SIZE * 2 + 1, VIRTUAL_QUEUE_PAGE_SIZE);
const merged = mergeVirtualQueueTracks(
mergeVirtualQueueTracks(first, second),
third,
);
assert.equal(merged.length, VIRTUAL_QUEUE_PAGE_SIZE * 3);
assert.equal(merged[0], first[0]);
assert.equal(merged.at(-1), third.at(-1));
});
test('deduplicates overlapping page boundaries and keeps existing row identity', () => {
const first = tracks(1, 3);
const duplicate = { id: 'replacement', astraQueuePosition: 3 };
const merged = mergeVirtualQueueTracks(first, [duplicate, ...tracks(4, 2)]);
assert.deepEqual(merged.map((track) => track.astraQueuePosition), [1, 2, 3, 4, 5]);
assert.equal(merged[2], first[2]);
});
test('prefetches one hundred rows before the loaded end', () => {
assert.equal(VIRTUAL_QUEUE_PREFETCH_ROWS, 100);
assert.equal(shouldPrefetchVirtualQueue(399, 500, true), false);
assert.equal(shouldPrefetchVirtualQueue(400, 500, true), true);
assert.equal(shouldPrefetchVirtualQueue(499, 500, false), false);
});
test('stops paging at the virtual queue end', () => {
assert.equal(nextVirtualQueuePageStart(tracks(1, 4), 0, 6), 5);
assert.equal(nextVirtualQueuePageStart(tracks(1, 5), 0, 6), null);
assert.equal(nextVirtualQueuePageStart([], 5, 6), null);
});
test('rejects stale generations and replaced playback sessions', () => {
assert.equal(isCurrentVirtualQueueRequest(4, 4, 'session-a', 'session-a'), true);
assert.equal(isCurrentVirtualQueueRequest(3, 4, 'session-a', 'session-a'), false);
assert.equal(isCurrentVirtualQueueRequest(4, 4, 'session-a', 'session-b'), false);
assert.equal(isCurrentVirtualQueueRequest(4, 4, 'session-a', undefined), false);
});
@@ -0,0 +1,87 @@
export const VIRTUAL_QUEUE_PAGE_SIZE = 250;
export const VIRTUAL_QUEUE_PREFETCH_ROWS = 100;
interface VirtualPositionedTrack {
astraQueuePosition?: unknown;
}
export function virtualQueuePosition(track: unknown): number | null {
const position = (track as VirtualPositionedTrack | null)?.astraQueuePosition;
return typeof position === 'number' && Number.isFinite(position) && position >= 0
? position
: null;
}
/**
* Keeps every page loaded during the current tray session. Existing rows win
* at duplicate page boundaries so memoized queue rows retain their identity.
*/
export function mergeVirtualQueueTracks<T>(
existing: readonly T[],
incoming: readonly T[],
): T[] {
const tracksByPosition = new Map<number, T>();
existing.forEach((track) => {
const position = virtualQueuePosition(track);
if (position !== null) tracksByPosition.set(position, track);
});
incoming.forEach((track) => {
const position = virtualQueuePosition(track);
if (position !== null && !tracksByPosition.has(position)) {
tracksByPosition.set(position, track);
}
});
return [...tracksByPosition.entries()]
.sort(([left], [right]) => left - right)
.map(([, track]) => track);
}
export function seedVirtualQueueTracks<T>(
playbackWindow: readonly T[],
activePosition: number,
totalCount: number,
): T[] {
return mergeVirtualQueueTracks(
[],
playbackWindow.filter((track) => {
const position = virtualQueuePosition(track);
return position !== null && position > activePosition && position < totalCount;
}),
);
}
export function nextVirtualQueuePageStart(
tracks: readonly unknown[],
activePosition: number,
totalCount: number,
): number | null {
const lastPosition = tracks.length > 0
? virtualQueuePosition(tracks[tracks.length - 1])
: null;
const start = (lastPosition ?? activePosition) + 1;
return start < totalCount ? start : null;
}
export function shouldPrefetchVirtualQueue(
lastVisibleIndex: number,
loadedCount: number,
hasMore: boolean,
prefetchRows = VIRTUAL_QUEUE_PREFETCH_ROWS,
): boolean {
if (!hasMore || loadedCount <= 0 || lastVisibleIndex < 0) return false;
return lastVisibleIndex >= Math.max(0, loadedCount - prefetchRows);
}
export function isCurrentVirtualQueueRequest(
requestGeneration: number,
currentGeneration: number,
requestSessionId: string,
currentSessionId: string | undefined,
): boolean {
return (
requestGeneration === currentGeneration &&
requestSessionId === currentSessionId
);
}