remove old queue

This commit is contained in:
Boof2015
2026-07-30 20:08:29 -04:00
parent c302c62a3f
commit fe7c5a70a4
15 changed files with 25 additions and 2213 deletions
@@ -16,7 +16,7 @@ interface SelectionActionBarProps {
onAddToPlaylist: () => void;
}
/** Bottom batch-action bar for library multi-select (QueueTray action-bar language). */
/** Bottom batch-action bar for library multi-select (native queue action-bar language). */
export function SelectionActionBar({
count,
onPlayNext,
@@ -1,7 +1,6 @@
import { StyleSheet, View } from 'react-native';
import { SegmentedControl } from '@/components/SegmentedControl';
import { LyricsBand } from '@/components/lyrics/LyricsBand';
import { QueueTray } from '@/components/queue/QueueTray';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import { seekTo } from '@/audio/playbackController';
import { spacing } from '@/theme';
@@ -41,7 +40,6 @@ export function NowPlayingCompanionPane({
const styles = useStyles();
const colors = useColors();
const companion = useSettingsStore((s) => s.nowPlayingCompanion);
const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled);
const setCompanion = useSettingsStore((s) => s.setNowPlayingCompanion);
const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0));
const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration));
@@ -74,15 +72,11 @@ export function NowPlayingCompanionPane({
</View>
<View style={styles.content}>
{companion === 'queue' ? (
nativeQueueEnabled ? (
<AstraQueueView
active={active}
palette={toNativeQueuePalette(colors)}
style={styles.nativeQueue}
/>
) : (
<QueueTray embedded onClose={noop} />
)
<AstraQueueView
active={active}
palette={toNativeQueuePalette(colors)}
style={styles.nativeQueue}
/>
) : track ? (
<NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
+11 -17
View File
@@ -34,7 +34,6 @@ import { Visualizer } from '@/components/Visualizer';
import { LyricsView } from '@/components/lyrics/LyricsView';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker';
import { QueueTray } from '@/components/queue/QueueTray';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import { TactilePressable } from '@/components/player/TactilePressable';
import { ScopeRack } from '@/components/player/ScopeRack';
@@ -180,7 +179,6 @@ export function NowPlayingOverlay() {
const nowPlayingAccentSource = useThemeStore((s) => s.nowPlayingAccentSource);
const coverArtAccentMethod = useThemeStore((s) => s.coverArtAccentMethod);
const scopeMode = useSettingsStore((s) => s.scopeMode);
const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled);
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
const scopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle);
@@ -385,7 +383,7 @@ export function NowPlayingOverlay() {
}
suspendPanForChildTransition();
setQueueOpen(true);
if (nativeQueueEnabled && !isDesktopTarget) {
if (!isDesktopTarget) {
void AstraQueue.present({ palette: toNativeQueuePalette(colors) }).catch((error) => {
console.warn('[queue] native presentation failed', error);
setQueueOpen(false);
@@ -752,17 +750,16 @@ export function NowPlayingOverlay() {
]
);
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
// Stable identity: RemoteQueueSheet is memo'd, so a fresh arrow would defeat it.
const closeQueue = useCallback(() => {
suspendPanForChildTransition();
if (nativeQueueEnabled && !isDesktopTarget) AstraQueue.dismiss();
if (!isDesktopTarget) AstraQueue.dismiss();
setQueueOpen(false);
// Shared values and the state setter remain stable for this overlay mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesktopTarget, nativeQueueEnabled]);
}, [isDesktopTarget]);
useEffect(() => {
if (!nativeQueueEnabled) return undefined;
const dismissed = AstraQueue.addListener('onDismissed', () => {
setQueueOpen(false);
});
@@ -797,15 +794,15 @@ export function NowPlayingOverlay() {
playbackRequest.remove();
revision.remove();
};
}, [nativeQueueEnabled]);
}, []);
// `colors` is accent-scoped to the cover art, so it changes on every track
// change. present() captured the palette once, which left an open sheet
// wearing the previous track's accent.
useEffect(() => {
if (!nativeQueueEnabled || isDesktopTarget || !queueOpen) return;
if (isDesktopTarget || !queueOpen) return;
AstraQueue.updatePalette(toNativeQueuePalette(colors));
}, [colors, isDesktopTarget, nativeQueueEnabled, queueOpen]);
}, [colors, isDesktopTarget, queueOpen]);
// Hardware back, innermost layer first: menu → queue tray → player. Registered
// only while open, so it sits above the focused screen's own handlers (LIFO)
@@ -1749,13 +1746,10 @@ export function NowPlayingOverlay() {
<SleepTimerControls />
</AppSheet>
) : null}
{queueOpen && !hasTabletCompanion && (
isDesktopTarget ? (
<RemoteQueueSheet onClose={closeQueue} />
) : !nativeQueueEnabled ? (
<QueueTray onClose={closeQueue} />
) : null
)}
{/* The native queue presents its own dialog from AstraQueue.present(). */}
{queueOpen && !hasTabletCompanion && isDesktopTarget ? (
<RemoteQueueSheet onClose={closeQueue} />
) : null}
<PlaybackTargetPicker
visible={targetPickerOpen}
onClose={() => setTargetPickerOpen(false)}
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,9 +1,10 @@
// Read-only queue sheet for the Desktop Remote: the desktop's current +
// upcoming tracks, tap-to-play. Deliberately NOT QueueTray — that component is
// welded to the local RNTP queue store (drag-reorder, swipe-remove,
// multi-select), none of which applies to a remote snapshot. Uses an INLINE
// BottomSheet like QueueTray does — BottomSheetModal's portal does not work in
// this app's screen setups (see queue-tray-sheet gotcha).
// upcoming tracks, tap-to-play. Deliberately NOT the native queue — that reads
// the local playback session straight out of Room (drag-reorder, swipe-remove,
// multi-select), none of which applies to a remote snapshot. This is the only
// remaining React Native queue surface. It uses an INLINE BottomSheet because
// BottomSheetModal's portal does not work in this app's screen setups (see
// queue-tray-sheet gotcha).
import { useCallback, useEffect, useMemo } from 'react';
import { Pressable, View } from 'react-native';
-132
View File
@@ -1,132 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
indexQueueEntriesByKey,
moveQueueEntry,
removeQueueEntryAt,
resolveSelectedQueueAction,
type KeyedQueueEntry,
} from './queueActions.ts';
function keyed(keys: string[]): KeyedQueueEntry[] {
return keys.map((key) => ({ key }));
}
function entryKeys(entries: readonly KeyedQueueEntry[]): string[] {
return entries.map((entry) => entry.key);
}
interface PositionedQueueEntry extends KeyedQueueEntry {
absoluteIndex: number;
}
function positioned(keys: string[], baseOffset: number): PositionedQueueEntry[] {
return keys.map((key, index) => ({ key, absoluteIndex: baseOffset + index }));
}
test('resolves each drag from the latest keyed queue order', () => {
let upcoming = keyed(['A', 'B', 'C', 'D']);
let currentIndices = indexQueueEntriesByKey(upcoming);
upcoming = moveQueueEntry(upcoming, currentIndices.B, 3);
assert.deepEqual(entryKeys(upcoming), ['A', 'C', 'D', 'B']);
currentIndices = indexQueueEntriesByKey(upcoming);
assert.equal(currentIndices.B, 3);
upcoming = moveQueueEntry(upcoming, currentIndices.B, 1);
assert.deepEqual(entryKeys(upcoming), ['A', 'B', 'C', 'D']);
});
test('resolves first, middle, and last swipe removals from the pre-mutation order', () => {
const scenarios = [
{ localIndex: 0, absoluteIndex: 7, remaining: ['B', 'C'] },
{ localIndex: 1, absoluteIndex: 8, remaining: ['A', 'C'] },
{ localIndex: 2, absoluteIndex: 9, remaining: ['A', 'B'] },
];
for (const scenario of scenarios) {
const action = removeQueueEntryAt(keyed(['A', 'B', 'C']), scenario.localIndex, 7);
if (!action) throw new Error('expected a remove action');
assert.equal(action.absoluteIndex, scenario.absoluteIndex);
assert.deepEqual(entryKeys(action.nextEntries), scenario.remaining);
}
});
test('keeps the swiped remove index after the optimistic ref changes to the next order', () => {
let entriesRef = positioned(['A', 'B', 'C'], 1);
const localIndex = entriesRef.findIndex((entry) => entry.key === 'B');
const action = removeQueueEntryAt(entriesRef, localIndex, 1);
if (!action) throw new Error('expected a remove action');
entriesRef = action.nextEntries;
assert.equal(action.absoluteIndex, 2);
assert.deepEqual(entryKeys(entriesRef), ['A', 'C']);
// Looking up localIndex after the optimistic update would incorrectly select C.
assert.equal(entriesRef[localIndex].absoluteIndex, 3);
});
test('removes duplicate tracks by unique row key and preserves the other occurrence', () => {
const upcoming = keyed(['same-track:0', 'same-track:1', 'tail']);
const action = removeQueueEntryAt(upcoming, 1, 20);
if (!action) throw new Error('expected a remove action');
assert.equal(action.absoluteIndex, 21);
assert.deepEqual(entryKeys(action.nextEntries), ['same-track:0', 'tail']);
});
test('uses the loaded virtual-page offset for swipe removal', () => {
const action = removeQueueEntryAt(keyed(['V500', 'V501', 'V502']), 2, 500);
if (!action) throw new Error('expected a remove action');
assert.equal(action.absoluteIndex, 502);
assert.deepEqual(entryKeys(action.nextEntries), ['V500', 'V501']);
});
test('re-resolves consecutive removals against each optimistic order', () => {
let upcoming = keyed(['A', 'B', 'C', 'D']);
const first = removeQueueEntryAt(upcoming, 1, 10);
if (!first) throw new Error('expected the first remove action');
upcoming = first.nextEntries;
const second = removeQueueEntryAt(upcoming, 1, 10);
if (!second) throw new Error('expected the second remove action');
upcoming = second.nextEntries;
assert.equal(first.absoluteIndex, 11);
assert.equal(second.absoluteIndex, 11);
assert.deepEqual(entryKeys(upcoming), ['A', 'D']);
});
test('captures play-next absolute index before optimistic reordering', () => {
let entriesRef = positioned(['A', 'B', 'C'], 40);
const snapshot = entriesRef;
const localIndex = snapshot.findIndex((entry) => entry.key === 'C');
const absoluteIndex = snapshot[localIndex].absoluteIndex;
entriesRef = moveQueueEntry(snapshot, localIndex, 0);
assert.equal(absoluteIndex, 42);
assert.deepEqual(entryKeys(entriesRef), ['C', 'A', 'B']);
// Looking up localIndex after the optimistic update would incorrectly select B.
assert.equal(entriesRef[localIndex].absoluteIndex, 41);
});
test('captures selected absolute indices before optimistic batch mutation', () => {
const upcoming = keyed(['A', 'B', 'C', 'D']);
const selectedKeys = new Set(['B', 'D']);
const action = resolveSelectedQueueAction(upcoming, selectedKeys, 1);
assert.deepEqual(action.absoluteIndices, [2, 4]);
assert.deepEqual(entryKeys(action.entriesWithoutSelected), ['A', 'C']);
assert.deepEqual(entryKeys(action.entriesWithSelectedFirst), ['B', 'D', 'A', 'C']);
const afterOptimisticRemoval = resolveSelectedQueueAction(
action.entriesWithoutSelected,
selectedKeys,
1
);
assert.deepEqual(afterOptimisticRemoval.absoluteIndices, []);
});
-81
View File
@@ -1,81 +0,0 @@
export interface KeyedQueueEntry {
key: string;
}
export type QueueIndexByKey = Record<string, number>;
export interface QueueItemRemoveAction<T extends KeyedQueueEntry> {
absoluteIndex: number;
nextEntries: T[];
}
export interface SelectedQueueAction<T extends KeyedQueueEntry> {
absoluteIndices: number[];
entriesWithSelectedFirst: T[];
entriesWithoutSelected: T[];
}
export function indexQueueEntriesByKey<T extends KeyedQueueEntry>(
entries: readonly T[]
): QueueIndexByKey {
const out: QueueIndexByKey = {};
entries.forEach((entry, index) => {
out[entry.key] = index;
});
return out;
}
export function moveQueueEntry<T>(entries: readonly T[], from: number, to: number): T[] {
const nextEntries = [...entries];
if (
from === to ||
from < 0 ||
to < 0 ||
from >= nextEntries.length ||
to >= nextEntries.length
) {
return nextEntries;
}
const [moved] = nextEntries.splice(from, 1);
nextEntries.splice(to, 0, moved);
return nextEntries;
}
export function removeQueueEntryAt<T extends KeyedQueueEntry>(
entries: readonly T[],
localIndex: number,
baseOffset: number
): QueueItemRemoveAction<T> | null {
if (localIndex < 0 || localIndex >= entries.length) return null;
return {
absoluteIndex: baseOffset + localIndex,
nextEntries: entries.filter((_, index) => index !== localIndex),
};
}
export function resolveSelectedQueueAction<T extends KeyedQueueEntry>(
entries: readonly T[],
selectedKeys: ReadonlySet<string>,
baseOffset: number
): SelectedQueueAction<T> {
const selectedEntries: T[] = [];
const remainingEntries: T[] = [];
const absoluteIndices: number[] = [];
entries.forEach((entry, index) => {
if (selectedKeys.has(entry.key)) {
selectedEntries.push(entry);
absoluteIndices.push(baseOffset + index);
} else {
remainingEntries.push(entry);
}
});
return {
absoluteIndices,
entriesWithSelectedFirst: [...selectedEntries, ...remainingEntries],
entriesWithoutSelected: remainingEntries,
};
}
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
QUEUE_INITIAL_RENDER_AHEAD_ROWS,
QUEUE_INITIAL_RENDER_DISTANCE,
QUEUE_RENDER_AHEAD_MIN_ROWS,
QUEUE_RENDER_WINDOW_MULTIPLIER,
QUEUE_ROW_HEIGHT,
queuePreviewRowCount,
queueRenderDistance,
} from './queuePerformance.ts';
test('queue opens with the former four-row render-ahead distance', () => {
assert.equal(QUEUE_INITIAL_RENDER_AHEAD_ROWS, 4);
assert.equal(QUEUE_INITIAL_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', () => {
assert.equal(queuePreviewRowCount(780), 4);
assert.equal(queuePreviewRowCount(900), 5);
assert.equal(queuePreviewRowCount(1400), 6);
});
test('queue preview remains safe for unusually short windows', () => {
assert.equal(queuePreviewRowCount(0), 1);
assert.equal(queuePreviewRowCount(320), 1);
});
-36
View File
@@ -1,36 +0,0 @@
export const QUEUE_ROW_HEIGHT = 64;
export const QUEUE_INITIAL_RENDER_AHEAD_ROWS = 4;
export const QUEUE_INITIAL_RENDER_DISTANCE =
QUEUE_ROW_HEIGHT * QUEUE_INITIAL_RENDER_AHEAD_ROWS;
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;
const QUEUE_PREVIEW_MAX_ROWS = 6;
/**
* The preview only fills the list portion of the initial sheet snap. Using the
* whole window height used to duplicate far more rows than could be visible
* while FlashList was mounting its own render-ahead window underneath.
*/
export function queuePreviewRowCount(windowHeight: number): number {
const initialListHeight = Math.max(
QUEUE_ROW_HEIGHT,
windowHeight * QUEUE_SHEET_INITIAL_FRACTION - QUEUE_PREVIEW_NON_LIST_HEIGHT
);
return Math.min(
QUEUE_PREVIEW_MAX_ROWS,
Math.max(1, Math.ceil(initialListHeight / QUEUE_ROW_HEIGHT))
);
}
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),
);
}
-46
View File
@@ -1,46 +0,0 @@
import { useEffect } from 'react';
import { Event, useTrackPlayerEvents, type Track as RntpTrack } from 'react-native-track-player';
import { useQueueStore } from '@/stores/queueStore';
import { usePlayerStore } from '@/stores/playerStore';
import { nativeIndexToAbsolute } from '@/audio/queueLoader';
export interface QueueSnapshot {
tracks: RntpTrack[];
activeIndex: number;
hasSnapshot: boolean;
refresh: () => Promise<void>;
}
/**
* Live view of the JS queue mirror. Opening the tray only falls back to RNTP's
* full native queue read when no playback action has populated the mirror yet.
*/
export function useQueue(active: boolean): QueueSnapshot {
const tracks = useQueueStore((s) => s.tracks);
const activeIndex = useQueueStore((s) => s.activeIndex);
const hasSnapshot = useQueueStore((s) => s.hasSnapshot);
const refresh = useQueueStore((s) => s.refreshFromNative);
const refreshActiveIndex = useQueueStore((s) => s.refreshActiveIndex);
const setActiveIndex = useQueueStore((s) => s.setActiveIndex);
const restoredSessionPending = usePlayerStore((s) => s.restoredSessionPending);
useEffect(() => {
if (!active) return;
if (restoredSessionPending) return;
if (hasSnapshot) void refreshActiveIndex();
else void refresh();
}, [active, hasSnapshot, refresh, refreshActiveIndex, restoredSessionPending]);
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], (event) => {
if (!active) return;
if (restoredSessionPending) return;
if (hasSnapshot) {
// Event indices are native — shifted while a chunked load is prepending the head.
setActiveIndex(event.index != null ? nativeIndexToAbsolute(event.index) : -1);
} else {
void refresh();
}
});
return { tracks, activeIndex, hasSnapshot, refresh };
}
@@ -1,80 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
VIRTUAL_QUEUE_PAGE_SIZE,
VIRTUAL_QUEUE_PREFETCH_ROWS,
isCurrentVirtualQueueRequest,
isVirtualQueueWaitingForTracks,
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('loads an empty virtual seed immediately when tracks remain', () => {
assert.equal(isVirtualQueueWaitingForTracks(0, 5, 10), true);
assert.equal(isVirtualQueueWaitingForTracks(1, 5, 10), false);
assert.equal(isVirtualQueueWaitingForTracks(0, 9, 10), false);
});
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);
});
-103
View File
@@ -1,103 +0,0 @@
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;
}
/**
* Break the empty-list mount deadlock: when the playback window has no usable
* upcoming rows but the virtual context still has tracks, paging cannot wait
* for FlashList's first paint because an empty list may never report one.
*/
export function isVirtualQueueWaitingForTracks(
visibleTrackCount: number,
activePosition: number,
totalCount: number,
): boolean {
return (
visibleTrackCount === 0
&& nextVirtualQueuePageStart([], activePosition, totalCount) !== 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
);
}