mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
remove old queue
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user