diff --git a/package.json b/package.json index 06d88ab..7dd5d70 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "ios": "expo run:ios", "web": "expo start --web", "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: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", diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index c68b165..46ba16b 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -533,7 +533,7 @@ export async function getVirtualQueuePage( const window = await AstraLibraryData.getPlaybackWindow( context.sessionId, Math.max(0, start), - Math.max(1, Math.min(100, limit)), + Math.max(1, Math.min(250, limit)), ); if (virtualContext !== context) return null; const boundedStart = Math.max(0, start); diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index 7a8955a..0332c9d 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -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(); @@ -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([]); - const virtualTracksRef = useRef([]); - 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(() => ( + 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(virtualTracks); + const virtualLoadGeneration = useRef(0); + const virtualLoadPromise = useRef | null>(null); + const virtualLoadQueued = useRef(false); + const virtualRetryTimer = useRef | null>(null); + const virtualPageLoaderRef = useRef<(reset: boolean) => Promise>(async () => {}); + + const loadVirtualPage = useCallback((reset: boolean): Promise => { + 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> = 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((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[] }) => { + 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 diff --git a/src/components/queue/queuePerformance.test.mts b/src/components/queue/queuePerformance.test.mts index 7999e81..800d2e5 100644 --- a/src/components/queue/queuePerformance.test.mts +++ b/src/components/queue/queuePerformance.test.mts @@ -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', () => { diff --git a/src/components/queue/queuePerformance.ts b/src/components/queue/queuePerformance.ts index b7bf1c0..5a47737 100644 --- a/src/components/queue/queuePerformance.ts +++ b/src/components/queue/queuePerformance.ts @@ -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), + ); +} diff --git a/src/components/queue/virtualQueuePaging.test.mts b/src/components/queue/virtualQueuePaging.test.mts new file mode 100644 index 0000000..3073c8f --- /dev/null +++ b/src/components/queue/virtualQueuePaging.test.mts @@ -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); +}); diff --git a/src/components/queue/virtualQueuePaging.ts b/src/components/queue/virtualQueuePaging.ts new file mode 100644 index 0000000..bbfe1e3 --- /dev/null +++ b/src/components/queue/virtualQueuePaging.ts @@ -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( + existing: readonly T[], + incoming: readonly T[], +): T[] { + const tracksByPosition = new Map(); + + 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( + 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 + ); +}