mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
fix queue bug
This commit is contained in:
@@ -62,6 +62,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",
|
||||||
"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 src/db/dynamicPlaylistSql.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",
|
"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",
|
||||||
|
|||||||
@@ -439,6 +439,10 @@ function moveOriginalOrderIfUnshuffled(fromIndex: number, toIndex: number): void
|
|||||||
originalOrder.splice(boundedTo, 0, moved);
|
originalOrder.splice(boundedTo, 0, moved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueueRemoveOptions {
|
||||||
|
updateMirror?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Replace everything after the current track with `upcoming` (in order). */
|
/** Replace everything after the current track with `upcoming` (in order). */
|
||||||
export async function setUpcoming(upcoming: RntpTrack[]): Promise<void> {
|
export async function setUpcoming(upcoming: RntpTrack[]): Promise<void> {
|
||||||
await queueLoadSettled();
|
await queueLoadSettled();
|
||||||
@@ -511,18 +515,28 @@ export async function requeueManyToTop(absoluteIndices: number[]): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a single track at an absolute queue index. */
|
/** Remove a single track at an absolute queue index. */
|
||||||
export async function removeFromQueue(absoluteIndex: number): Promise<void> {
|
export async function removeFromQueue(
|
||||||
|
absoluteIndex: number,
|
||||||
|
options: QueueRemoveOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
await queueLoadSettled();
|
await queueLoadSettled();
|
||||||
await TrackPlayer.remove(absoluteIndex);
|
await TrackPlayer.remove(absoluteIndex);
|
||||||
useQueueStore.getState().removeIndices([absoluteIndex]);
|
if (options.updateMirror !== false) {
|
||||||
|
useQueueStore.getState().removeIndices([absoluteIndex]);
|
||||||
|
}
|
||||||
syncOriginalOrderFromMirrorIfUnshuffled();
|
syncOriginalOrderFromMirrorIfUnshuffled();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a group of tracks at absolute queue indices. */
|
/** Remove a group of tracks at absolute queue indices. */
|
||||||
export async function removeManyFromQueue(absoluteIndices: number[]): Promise<void> {
|
export async function removeManyFromQueue(
|
||||||
|
absoluteIndices: number[],
|
||||||
|
options: QueueRemoveOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
if (absoluteIndices.length === 0) return;
|
if (absoluteIndices.length === 0) return;
|
||||||
await queueLoadSettled();
|
await queueLoadSettled();
|
||||||
await TrackPlayer.remove(absoluteIndices);
|
await TrackPlayer.remove(absoluteIndices);
|
||||||
useQueueStore.getState().removeIndices(absoluteIndices);
|
if (options.updateMirror !== false) {
|
||||||
|
useQueueStore.getState().removeIndices(absoluteIndices);
|
||||||
|
}
|
||||||
syncOriginalOrderFromMirrorIfUnshuffled();
|
syncOriginalOrderFromMirrorIfUnshuffled();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ import {
|
|||||||
setUpcoming
|
setUpcoming
|
||||||
} from '@/audio/playbackController';
|
} from '@/audio/playbackController';
|
||||||
import { useQueue } from './useQueue';
|
import { useQueue } from './useQueue';
|
||||||
|
import {
|
||||||
|
removeQueueEntryAt,
|
||||||
|
resolveSelectedQueueAction,
|
||||||
|
} from './queueActions';
|
||||||
|
|
||||||
const QUEUE_ROW_HEIGHT = 64;
|
const QUEUE_ROW_HEIGHT = 64;
|
||||||
const ART = 42;
|
const ART = 42;
|
||||||
@@ -399,9 +403,11 @@ export function QueueTray({ onClose }: QueueTrayProps) {
|
|||||||
|
|
||||||
const remove = useCallback(
|
const remove = useCallback(
|
||||||
(localIndex: number) => {
|
(localIndex: number) => {
|
||||||
const nextEntries = entriesRef.current.filter((_, index) => index !== localIndex);
|
const action = removeQueueEntryAt(entriesRef.current, localIndex, baseOffset);
|
||||||
setOptimisticEntries(nextEntries);
|
if (!action) return;
|
||||||
runAndRefresh(removeFromQueue(baseOffset + localIndex));
|
|
||||||
|
setOptimisticEntries(action.nextEntries);
|
||||||
|
runAndRefresh(removeFromQueue(action.absoluteIndex, { updateMirror: false }));
|
||||||
},
|
},
|
||||||
[baseOffset, runAndRefresh, setOptimisticEntries]
|
[baseOffset, runAndRefresh, setOptimisticEntries]
|
||||||
);
|
);
|
||||||
@@ -420,30 +426,29 @@ export function QueueTray({ onClose }: QueueTrayProps) {
|
|||||||
setSelectedKeys(new Set());
|
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 groupPlayNext = useCallback(() => {
|
||||||
const selected = new Set(visibleSelectedKeys);
|
const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset);
|
||||||
const moved = entriesRef.current.filter((entry) => selected.has(entry.key));
|
if (action.absoluteIndices.length === 0) {
|
||||||
const rest = entriesRef.current.filter((entry) => !selected.has(entry.key));
|
exitEdit();
|
||||||
setOptimisticEntries([...moved, ...rest]);
|
return;
|
||||||
runAndRefresh(requeueManyToTop(selectedAbsoluteIndices()));
|
}
|
||||||
|
|
||||||
|
setOptimisticEntries(action.entriesWithSelectedFirst);
|
||||||
|
runAndRefresh(requeueManyToTop(action.absoluteIndices));
|
||||||
exitEdit();
|
exitEdit();
|
||||||
}, [exitEdit, runAndRefresh, selectedAbsoluteIndices, setOptimisticEntries, visibleSelectedKeys]);
|
}, [baseOffset, exitEdit, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]);
|
||||||
|
|
||||||
const groupRemove = useCallback(() => {
|
const groupRemove = useCallback(() => {
|
||||||
const selected = new Set(visibleSelectedKeys);
|
const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset);
|
||||||
const nextEntries = entriesRef.current.filter((entry) => !selected.has(entry.key));
|
if (action.absoluteIndices.length === 0) {
|
||||||
setOptimisticEntries(nextEntries);
|
exitEdit();
|
||||||
runAndRefresh(removeManyFromQueue(selectedAbsoluteIndices()));
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptimisticEntries(action.entriesWithoutSelected);
|
||||||
|
runAndRefresh(removeManyFromQueue(action.absoluteIndices, { updateMirror: false }));
|
||||||
exitEdit();
|
exitEdit();
|
||||||
}, [exitEdit, runAndRefresh, selectedAbsoluteIndices, setOptimisticEntries, visibleSelectedKeys]);
|
}, [baseOffset, exitEdit, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
|||||||
@@ -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, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export interface KeyedQueueEntry {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueItemRemoveAction<T extends KeyedQueueEntry> {
|
||||||
|
absoluteIndex: number;
|
||||||
|
nextEntries: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedQueueAction<T extends KeyedQueueEntry> {
|
||||||
|
absoluteIndices: number[];
|
||||||
|
entriesWithSelectedFirst: T[];
|
||||||
|
entriesWithoutSelected: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user