diff --git a/package.json b/package.json index 97d2f2e..2573d03 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,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", "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 src/db/dynamicPlaylistSql.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 b0f79b5..617bdf6 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -439,6 +439,10 @@ function moveOriginalOrderIfUnshuffled(fromIndex: number, toIndex: number): void originalOrder.splice(boundedTo, 0, moved); } +interface QueueRemoveOptions { + updateMirror?: boolean; +} + /** Replace everything after the current track with `upcoming` (in order). */ export async function setUpcoming(upcoming: RntpTrack[]): Promise { await queueLoadSettled(); @@ -511,18 +515,28 @@ export async function requeueManyToTop(absoluteIndices: number[]): Promise } /** Remove a single track at an absolute queue index. */ -export async function removeFromQueue(absoluteIndex: number): Promise { +export async function removeFromQueue( + absoluteIndex: number, + options: QueueRemoveOptions = {} +): Promise { await queueLoadSettled(); await TrackPlayer.remove(absoluteIndex); - useQueueStore.getState().removeIndices([absoluteIndex]); + if (options.updateMirror !== false) { + useQueueStore.getState().removeIndices([absoluteIndex]); + } syncOriginalOrderFromMirrorIfUnshuffled(); } /** Remove a group of tracks at absolute queue indices. */ -export async function removeManyFromQueue(absoluteIndices: number[]): Promise { +export async function removeManyFromQueue( + absoluteIndices: number[], + options: QueueRemoveOptions = {} +): Promise { if (absoluteIndices.length === 0) return; await queueLoadSettled(); await TrackPlayer.remove(absoluteIndices); - useQueueStore.getState().removeIndices(absoluteIndices); + if (options.updateMirror !== false) { + useQueueStore.getState().removeIndices(absoluteIndices); + } syncOriginalOrderFromMirrorIfUnshuffled(); } diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index 0271c2d..a0a9adb 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -56,6 +56,10 @@ import { setUpcoming } from '@/audio/playbackController'; import { useQueue } from './useQueue'; +import { + removeQueueEntryAt, + resolveSelectedQueueAction, +} from './queueActions'; const QUEUE_ROW_HEIGHT = 64; const ART = 42; @@ -399,9 +403,11 @@ export function QueueTray({ onClose }: QueueTrayProps) { const remove = useCallback( (localIndex: number) => { - const nextEntries = entriesRef.current.filter((_, index) => index !== localIndex); - setOptimisticEntries(nextEntries); - runAndRefresh(removeFromQueue(baseOffset + localIndex)); + const action = removeQueueEntryAt(entriesRef.current, localIndex, baseOffset); + if (!action) return; + + setOptimisticEntries(action.nextEntries); + runAndRefresh(removeFromQueue(action.absoluteIndex, { updateMirror: false })); }, [baseOffset, runAndRefresh, setOptimisticEntries] ); @@ -420,30 +426,29 @@ export function QueueTray({ onClose }: QueueTrayProps) { setSelectedKeys(new Set()); }, []); - const selectedAbsoluteIndices = useCallback((): number[] => { - const indices: number[] = []; - entriesRef.current.forEach((entry, index) => { - if (visibleSelectedKeys.has(entry.key)) indices.push(baseOffset + index); - }); - return indices; - }, [baseOffset, visibleSelectedKeys]); - const groupPlayNext = useCallback(() => { - const selected = new Set(visibleSelectedKeys); - const moved = entriesRef.current.filter((entry) => selected.has(entry.key)); - const rest = entriesRef.current.filter((entry) => !selected.has(entry.key)); - setOptimisticEntries([...moved, ...rest]); - runAndRefresh(requeueManyToTop(selectedAbsoluteIndices())); + const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset); + if (action.absoluteIndices.length === 0) { + exitEdit(); + return; + } + + setOptimisticEntries(action.entriesWithSelectedFirst); + runAndRefresh(requeueManyToTop(action.absoluteIndices)); exitEdit(); - }, [exitEdit, runAndRefresh, selectedAbsoluteIndices, setOptimisticEntries, visibleSelectedKeys]); + }, [baseOffset, exitEdit, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]); const groupRemove = useCallback(() => { - const selected = new Set(visibleSelectedKeys); - const nextEntries = entriesRef.current.filter((entry) => !selected.has(entry.key)); - setOptimisticEntries(nextEntries); - runAndRefresh(removeManyFromQueue(selectedAbsoluteIndices())); + const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset); + if (action.absoluteIndices.length === 0) { + exitEdit(); + return; + } + + setOptimisticEntries(action.entriesWithoutSelected); + runAndRefresh(removeManyFromQueue(action.absoluteIndices, { updateMirror: false })); exitEdit(); - }, [exitEdit, runAndRefresh, selectedAbsoluteIndices, setOptimisticEntries, visibleSelectedKeys]); + }, [baseOffset, exitEdit, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( diff --git a/src/components/queue/queueActions.test.mts b/src/components/queue/queueActions.test.mts new file mode 100644 index 0000000..54c8b23 --- /dev/null +++ b/src/components/queue/queueActions.test.mts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + 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); +} + +test('resolves swipe remove before the optimistic mirror mutation', () => { + const upcoming = keyed(['A', 'B', 'C']); + const action = removeQueueEntryAt(upcoming, 1, 1); + + if (!action) throw new Error('expected a remove action'); + assert.equal(action.absoluteIndex, 2); + assert.deepEqual(entryKeys(action.nextEntries), ['A', 'C']); + + const mirrorAfterNativeCompletionWithSkippedUpdate = action.nextEntries; + assert.deepEqual(entryKeys(mirrorAfterNativeCompletionWithSkippedUpdate), ['A', 'C']); +}); + +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, []); +}); diff --git a/src/components/queue/queueActions.ts b/src/components/queue/queueActions.ts new file mode 100644 index 0000000..5a3752f --- /dev/null +++ b/src/components/queue/queueActions.ts @@ -0,0 +1,52 @@ +export interface KeyedQueueEntry { + key: string; +} + +export interface QueueItemRemoveAction { + absoluteIndex: number; + nextEntries: T[]; +} + +export interface SelectedQueueAction { + absoluteIndices: number[]; + entriesWithSelectedFirst: T[]; + entriesWithoutSelected: T[]; +} + +export function removeQueueEntryAt( + entries: readonly T[], + localIndex: number, + baseOffset: number +): QueueItemRemoveAction | null { + if (localIndex < 0 || localIndex >= entries.length) return null; + + return { + absoluteIndex: baseOffset + localIndex, + nextEntries: entries.filter((_, index) => index !== localIndex), + }; +} + +export function resolveSelectedQueueAction( + entries: readonly T[], + selectedKeys: ReadonlySet, + baseOffset: number +): SelectedQueueAction { + 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, + }; +}