mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 04:59:53 +02:00
fix queue items disappearing on fast scrolling
This commit is contained in:
+1
-1
@@ -64,7 +64,7 @@
|
|||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts src/components/swipeableRowState.test.mts",
|
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts src/components/queue/virtualQueuePaging.test.mts src/components/swipeableRowState.test.mts",
|
||||||
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
||||||
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
||||||
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ export async function getVirtualQueuePage(
|
|||||||
const window = await AstraLibraryData.getPlaybackWindow<DbTrack>(
|
const window = await AstraLibraryData.getPlaybackWindow<DbTrack>(
|
||||||
context.sessionId,
|
context.sessionId,
|
||||||
Math.max(0, start),
|
Math.max(0, start),
|
||||||
Math.max(1, Math.min(100, limit)),
|
Math.max(1, Math.min(250, limit)),
|
||||||
);
|
);
|
||||||
if (virtualContext !== context) return null;
|
if (virtualContext !== context) return null;
|
||||||
const boundedStart = Math.max(0, start);
|
const boundedStart = Math.max(0, start);
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ import BottomSheet, {
|
|||||||
type BottomSheetHandleProps,
|
type BottomSheetHandleProps,
|
||||||
useBottomSheetScrollableCreator
|
useBottomSheetScrollableCreator
|
||||||
} from '@gorhom/bottom-sheet';
|
} from '@gorhom/bottom-sheet';
|
||||||
import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
|
import {
|
||||||
|
FlashList,
|
||||||
|
type ListRenderItemInfo,
|
||||||
|
type ViewToken,
|
||||||
|
} from '@shopify/flash-list';
|
||||||
import {
|
import {
|
||||||
Gesture,
|
Gesture,
|
||||||
GestureDetector,
|
GestureDetector,
|
||||||
@@ -73,11 +77,19 @@ import {
|
|||||||
type QueueIndexByKey,
|
type QueueIndexByKey,
|
||||||
} from './queueActions';
|
} from './queueActions';
|
||||||
import {
|
import {
|
||||||
QUEUE_RENDER_DISTANCE,
|
|
||||||
QUEUE_ROW_HEIGHT,
|
QUEUE_ROW_HEIGHT,
|
||||||
queuePreviewRowCount,
|
queuePreviewRowCount,
|
||||||
|
queueRenderDistance,
|
||||||
} from './queuePerformance';
|
} from './queuePerformance';
|
||||||
import { QueueSheetHandle } from './QueueSheetHandle';
|
import { QueueSheetHandle } from './QueueSheetHandle';
|
||||||
|
import {
|
||||||
|
VIRTUAL_QUEUE_PAGE_SIZE,
|
||||||
|
isCurrentVirtualQueueRequest,
|
||||||
|
mergeVirtualQueueTracks,
|
||||||
|
nextVirtualQueuePageStart,
|
||||||
|
seedVirtualQueueTracks,
|
||||||
|
shouldPrefetchVirtualQueue,
|
||||||
|
} from './virtualQueuePaging';
|
||||||
|
|
||||||
const ART = 42;
|
const ART = 42;
|
||||||
const EMPTY_KEY_SET = new Set<string>();
|
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 [listPainted, setListPainted] = useState(false);
|
||||||
const onListLoad = useCallback(() => setListPainted(true), []);
|
const onListLoad = useCallback(() => setListPainted(true), []);
|
||||||
const previewCount = queuePreviewRowCount(windowHeight);
|
const previewCount = queuePreviewRowCount(windowHeight);
|
||||||
|
const renderDistance = queueRenderDistance(windowHeight);
|
||||||
// Bottom padding clears the gesture-nav inset so the last row is fully
|
// Bottom padding clears the gesture-nav inset so the last row is fully
|
||||||
// scrollable into view at the 100% snap.
|
// scrollable into view at the 100% snap.
|
||||||
const listContentStyle = useMemo(
|
const listContentStyle = useMemo(
|
||||||
@@ -211,69 +224,176 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
() => (activeIndex >= 0 ? tracks.slice(activeIndex + 1) : tracks),
|
() => (activeIndex >= 0 ? tracks.slice(activeIndex + 1) : tracks),
|
||||||
[tracks, activeIndex]
|
[tracks, activeIndex]
|
||||||
);
|
);
|
||||||
|
const rollingUpcomingTracksRef = useRef(rollingUpcomingTracks);
|
||||||
|
rollingUpcomingTracksRef.current = rollingUpcomingTracks;
|
||||||
const virtualState = getVirtualQueueState();
|
const virtualState = getVirtualQueueState();
|
||||||
const virtualMode = virtualState !== null;
|
const virtualMode = virtualState !== null;
|
||||||
const virtualActivePosition = virtualState?.activePosition ?? -1;
|
const virtualActivePosition = virtualState?.activePosition ?? -1;
|
||||||
const [virtualTracks, setVirtualTracks] = useState<RntpTrack[]>([]);
|
const [virtualTracks, setVirtualTracks] = useState<RntpTrack[]>(() => (
|
||||||
const virtualTracksRef = useRef<RntpTrack[]>([]);
|
virtualState
|
||||||
const virtualLoadGeneration = useRef(0);
|
? seedVirtualQueueTracks(
|
||||||
const virtualLoading = useRef(false);
|
rollingUpcomingTracks,
|
||||||
|
virtualState.activePosition,
|
||||||
const loadVirtualPage = useCallback(async (reset: boolean) => {
|
virtualState.totalCount,
|
||||||
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 incoming = page.items
|
));
|
||||||
.filter((item) => item.queuePosition >= start && !existingPositions.has(item.queuePosition))
|
const virtualTracksRef = useRef<RntpTrack[]>(virtualTracks);
|
||||||
.map((item) => item.track);
|
const virtualLoadGeneration = useRef(0);
|
||||||
const next = reset ? incoming : [...existing, ...incoming];
|
const virtualLoadPromise = useRef<Promise<void> | null>(null);
|
||||||
// Keep no more than five tray pages in JS.
|
const virtualLoadQueued = useRef(false);
|
||||||
const bounded = next.slice(-500);
|
const virtualRetryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
virtualTracksRef.current = bounded;
|
const virtualPageLoaderRef = useRef<(reset: boolean) => Promise<void>>(async () => {});
|
||||||
setVirtualTracks(bounded);
|
|
||||||
} finally {
|
const loadVirtualPage = useCallback((reset: boolean): Promise<void> => {
|
||||||
if (generation === virtualLoadGeneration.current) virtualLoading.current = false;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!virtualMode) {
|
|
||||||
virtualLoadGeneration.current += 1;
|
|
||||||
virtualTracksRef.current = [];
|
|
||||||
setVirtualTracks([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadVirtualPage(true);
|
void loadVirtualPage(true);
|
||||||
}, [loadVirtualPage, virtualActivePosition, virtualMode, virtualState?.sessionId]);
|
}, [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 upcomingTracks = virtualMode ? virtualTracks : rollingUpcomingTracks;
|
||||||
const upcomingTotal = virtualState
|
const upcomingTotal = virtualState
|
||||||
? Math.max(0, virtualState.totalCount - virtualState.activePosition - 1)
|
? Math.max(0, virtualState.totalCount - virtualState.activePosition - 1)
|
||||||
@@ -882,12 +1002,13 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
data={entries}
|
data={entries}
|
||||||
scrollEnabled
|
scrollEnabled
|
||||||
keyExtractor={(item) => item.key}
|
keyExtractor={(item) => item.key}
|
||||||
drawDistance={QUEUE_RENDER_DISTANCE}
|
drawDistance={renderDistance}
|
||||||
maintainVisibleContentPosition={{ disabled: true }}
|
maintainVisibleContentPosition={{ disabled: true }}
|
||||||
renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent}
|
renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={listExtraData}
|
extraData={listExtraData}
|
||||||
onLoad={onListLoad}
|
onLoad={onListLoad}
|
||||||
|
onViewableItemsChanged={virtualMode ? onQueueViewableItemsChanged : undefined}
|
||||||
onEndReached={virtualMode ? () => void loadVirtualPage(false) : undefined}
|
onEndReached={virtualMode ? () => void loadVirtualPage(false) : undefined}
|
||||||
onEndReachedThreshold={0.6}
|
onEndReachedThreshold={0.6}
|
||||||
contentContainerStyle={embedded
|
contentContainerStyle={embedded
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
QUEUE_RENDER_AHEAD_ROWS,
|
QUEUE_RENDER_AHEAD_MIN_ROWS,
|
||||||
QUEUE_RENDER_DISTANCE,
|
QUEUE_RENDER_WINDOW_MULTIPLIER,
|
||||||
QUEUE_ROW_HEIGHT,
|
QUEUE_ROW_HEIGHT,
|
||||||
queuePreviewRowCount,
|
queuePreviewRowCount,
|
||||||
|
queueRenderDistance,
|
||||||
} from './queuePerformance.ts';
|
} from './queuePerformance.ts';
|
||||||
|
|
||||||
test('queue render-ahead stays bounded to four rows', () => {
|
test('queue render-ahead covers two windows with a twelve-row minimum', () => {
|
||||||
assert.equal(QUEUE_RENDER_AHEAD_ROWS, 4);
|
assert.equal(QUEUE_RENDER_AHEAD_MIN_ROWS, 12);
|
||||||
assert.equal(QUEUE_RENDER_DISTANCE, QUEUE_ROW_HEIGHT * 4);
|
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', () => {
|
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_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_SHEET_INITIAL_FRACTION = 0.58;
|
||||||
const QUEUE_PREVIEW_NON_LIST_HEIGHT = 220;
|
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