mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
fix queue items disappearing on fast scrolling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user