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:
@@ -59,8 +59,11 @@ import {
|
|||||||
} from '@/audio/playbackController';
|
} from '@/audio/playbackController';
|
||||||
import { useQueue } from './useQueue';
|
import { useQueue } from './useQueue';
|
||||||
import {
|
import {
|
||||||
|
indexQueueEntriesByKey,
|
||||||
|
moveQueueEntry,
|
||||||
removeQueueEntryAt,
|
removeQueueEntryAt,
|
||||||
resolveSelectedQueueAction,
|
resolveSelectedQueueAction,
|
||||||
|
type QueueIndexByKey,
|
||||||
} from './queueActions';
|
} from './queueActions';
|
||||||
|
|
||||||
const QUEUE_ROW_HEIGHT = 64;
|
const QUEUE_ROW_HEIGHT = 64;
|
||||||
@@ -73,8 +76,6 @@ interface QueueEntry {
|
|||||||
track: RntpTrack;
|
track: RntpTrack;
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueueIndexByKey = Record<string, number>;
|
|
||||||
|
|
||||||
function rntpKey(track: RntpTrack): string {
|
function rntpKey(track: RntpTrack): string {
|
||||||
return String(track.id ?? track.url);
|
return String(track.id ?? track.url);
|
||||||
}
|
}
|
||||||
@@ -99,21 +100,6 @@ function queueCountLabel(count: number): string {
|
|||||||
return `${count} songs next`;
|
return `${count} songs next`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function arrayMove<T>(items: readonly T[], from: number, to: number): T[] {
|
|
||||||
const out = [...items];
|
|
||||||
const [moved] = out.splice(from, 1);
|
|
||||||
out.splice(to, 0, moved);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function indexByEntryKey(entries: readonly QueueEntry[]): QueueIndexByKey {
|
|
||||||
const out: QueueIndexByKey = {};
|
|
||||||
entries.forEach((entry, index) => {
|
|
||||||
out[entry.key] = index;
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampLocal(value: number, len: number): number {
|
function clampLocal(value: number, len: number): number {
|
||||||
'worklet';
|
'worklet';
|
||||||
return Math.max(0, Math.min(len - 1, value));
|
return Math.max(0, Math.min(len - 1, value));
|
||||||
@@ -262,7 +248,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
|
|
||||||
const setVisibleEntries = useCallback((nextEntries: QueueEntry[]) => {
|
const setVisibleEntries = useCallback((nextEntries: QueueEntry[]) => {
|
||||||
entriesRef.current = nextEntries;
|
entriesRef.current = nextEntries;
|
||||||
if (dragInFlightRef.current) updateDragIndexMap(indexByEntryKey(nextEntries));
|
if (dragInFlightRef.current) updateDragIndexMap(indexQueueEntriesByKey(nextEntries));
|
||||||
setEntries(nextEntries);
|
setEntries(nextEntries);
|
||||||
}, [updateDragIndexMap]);
|
}, [updateDragIndexMap]);
|
||||||
|
|
||||||
@@ -280,9 +266,6 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
|
|
||||||
frame = requestAnimationFrame(() => {
|
frame = requestAnimationFrame(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setEditMode(false);
|
|
||||||
setSelectedKeys(new Set());
|
|
||||||
|
|
||||||
setEntries((previous) => {
|
setEntries((previous) => {
|
||||||
const next = hasSnapshot
|
const next = hasSnapshot
|
||||||
? reconcileQueueEntries(
|
? reconcileQueueEntries(
|
||||||
@@ -292,7 +275,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
entriesRef.current = next;
|
entriesRef.current = next;
|
||||||
if (dragInFlightRef.current) updateDragIndexMap(indexByEntryKey(next));
|
if (dragInFlightRef.current) updateDragIndexMap(indexQueueEntriesByKey(next));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -301,7 +284,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (frame != null) cancelAnimationFrame(frame);
|
if (frame != null) cancelAnimationFrame(frame);
|
||||||
};
|
};
|
||||||
}, [hasSnapshot, setVisibleEntries, upcomingTracks, updateDragIndexMap]);
|
}, [hasSnapshot, upcomingTracks, updateDragIndexMap]);
|
||||||
|
|
||||||
const visibleSelectedKeys = useMemo(() => {
|
const visibleSelectedKeys = useMemo(() => {
|
||||||
if (selectedKeys.size === 0) return EMPTY_KEY_SET;
|
if (selectedKeys.size === 0) return EMPTY_KEY_SET;
|
||||||
@@ -330,10 +313,11 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
(from: number, to: number) => {
|
(from: number, to: number) => {
|
||||||
const snapshot = entriesRef.current;
|
const snapshot = entriesRef.current;
|
||||||
if (from === to || from < 0 || to < 0 || from >= snapshot.length || to >= snapshot.length) {
|
if (from === to || from < 0 || to < 0 || from >= snapshot.length || to >= snapshot.length) {
|
||||||
|
clearDragState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEntries = arrayMove(snapshot, from, to);
|
const nextEntries = moveQueueEntry(snapshot, from, to);
|
||||||
playHaptic('queueDrop');
|
playHaptic('queueDrop');
|
||||||
setVisibleEntries(nextEntries);
|
setVisibleEntries(nextEntries);
|
||||||
clearDragAfterReorderCommit();
|
clearDragAfterReorderCommit();
|
||||||
@@ -343,13 +327,37 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
nextEntries.map((entry) => entry.track)
|
nextEntries.map((entry) => entry.track)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[baseOffset, clearDragAfterReorderCommit, commitNativeMove, setVisibleEntries]
|
[baseOffset, clearDragAfterReorderCommit, clearDragState, commitNativeMove, setVisibleEntries]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDragArm = useCallback(() => {
|
const onDragArm = useCallback(
|
||||||
dragInFlightRef.current = true;
|
(entryKey: string) => {
|
||||||
playHaptic('queueLift');
|
dragInFlightRef.current = true;
|
||||||
}, []);
|
const indexMap = indexQueueEntriesByKey(entriesRef.current);
|
||||||
|
const currentIndex = indexMap[entryKey] ?? -1;
|
||||||
|
|
||||||
|
runOnUI(
|
||||||
|
(
|
||||||
|
active: SharedValue<boolean>,
|
||||||
|
key: SharedValue<string>,
|
||||||
|
start: SharedValue<number>,
|
||||||
|
target: SharedValue<number>,
|
||||||
|
sharedIndexMap: SharedValue<QueueIndexByKey>,
|
||||||
|
armedKey: string,
|
||||||
|
armedIndex: number,
|
||||||
|
nextIndexMap: QueueIndexByKey
|
||||||
|
) => {
|
||||||
|
'worklet';
|
||||||
|
if (!active.value || key.value !== armedKey) return;
|
||||||
|
sharedIndexMap.value = nextIndexMap;
|
||||||
|
start.value = armedIndex;
|
||||||
|
target.value = armedIndex;
|
||||||
|
}
|
||||||
|
)(dActive, dKey, dStart, dTarget, dIndexByKey, entryKey, currentIndex, indexMap);
|
||||||
|
playHaptic('queueLift');
|
||||||
|
},
|
||||||
|
[dActive, dIndexByKey, dKey, dStart, dTarget]
|
||||||
|
);
|
||||||
|
|
||||||
const onDragAbort = useCallback(() => {
|
const onDragAbort = useCallback(() => {
|
||||||
dragInFlightRef.current = false;
|
dragInFlightRef.current = false;
|
||||||
@@ -357,24 +365,23 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
|
|
||||||
const makeDragGesture = useCallback(
|
const makeDragGesture = useCallback(
|
||||||
(
|
(
|
||||||
localIndex: number,
|
|
||||||
longPress: boolean,
|
longPress: boolean,
|
||||||
entryKey: string,
|
entryKey: string,
|
||||||
entryCount: number
|
entryCount: number
|
||||||
): GestureType => {
|
): GestureType => {
|
||||||
const gesture = Gesture.Pan()
|
const gesture = Gesture.Pan()
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
const currentIndex = dIndexByKey.value[entryKey] ?? localIndex;
|
dStart.value = -1;
|
||||||
dStart.value = currentIndex;
|
dTarget.value = -1;
|
||||||
dTarget.value = currentIndex;
|
|
||||||
dTy.value = 0;
|
dTy.value = 0;
|
||||||
dKey.value = entryKey;
|
dKey.value = entryKey;
|
||||||
dSettling.value = false;
|
dSettling.value = false;
|
||||||
dActive.value = true;
|
dActive.value = true;
|
||||||
runOnJS(onDragArm)();
|
runOnJS(onDragArm)(entryKey);
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
dTy.value = event.translationY;
|
dTy.value = event.translationY;
|
||||||
|
if (dStart.value < 0) return;
|
||||||
const nextTarget = clampLocal(
|
const nextTarget = clampLocal(
|
||||||
Math.round(dStart.value + event.translationY / QUEUE_ROW_HEIGHT),
|
Math.round(dStart.value + event.translationY / QUEUE_ROW_HEIGHT),
|
||||||
entryCount
|
entryCount
|
||||||
@@ -407,9 +414,20 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
dSettling.value = true;
|
dSettling.value = true;
|
||||||
runOnJS(finishDrag)(from, to);
|
runOnJS(finishDrag)(from, to);
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.onFinalize((_event, success) => {
|
||||||
|
if (success || dKey.value !== entryKey) return;
|
||||||
|
dActive.value = false;
|
||||||
|
dTy.value = 0;
|
||||||
|
dStart.value = -1;
|
||||||
|
dTarget.value = -1;
|
||||||
|
dSettling.value = false;
|
||||||
|
dKey.value = '';
|
||||||
|
dIndexByKey.value = {};
|
||||||
|
runOnJS(onDragAbort)();
|
||||||
});
|
});
|
||||||
|
|
||||||
return longPress ? gesture.activateAfterLongPress(250) : gesture;
|
return longPress ? gesture.activateAfterLongPress(250) : gesture.minDistance(1);
|
||||||
},
|
},
|
||||||
[dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy, finishDrag, onDragAbort, onDragArm]
|
[dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy, finishDrag, onDragAbort, onDragArm]
|
||||||
);
|
);
|
||||||
@@ -430,7 +448,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
|
|
||||||
const playNext = useCallback(
|
const playNext = useCallback(
|
||||||
(localIndex: number) => {
|
(localIndex: number) => {
|
||||||
const nextEntries = arrayMove(entriesRef.current, localIndex, 0);
|
const nextEntries = moveQueueEntry(entriesRef.current, localIndex, 0);
|
||||||
setOptimisticEntries(nextEntries);
|
setOptimisticEntries(nextEntries);
|
||||||
runAndRefresh(requeueToTop(baseOffset + localIndex));
|
runAndRefresh(requeueToTop(baseOffset + localIndex));
|
||||||
},
|
},
|
||||||
@@ -462,29 +480,37 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editMode && hasSnapshot && entries.length === 0) exitEdit();
|
||||||
|
}, [editMode, entries.length, exitEdit, hasSnapshot]);
|
||||||
|
|
||||||
const groupPlayNext = useCallback(() => {
|
const groupPlayNext = useCallback(() => {
|
||||||
const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset);
|
const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset);
|
||||||
if (action.absoluteIndices.length === 0) {
|
if (action.absoluteIndices.length === 0) {
|
||||||
exitEdit();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOptimisticEntries(action.entriesWithSelectedFirst);
|
setOptimisticEntries(action.entriesWithSelectedFirst);
|
||||||
runAndRefresh(requeueManyToTop(action.absoluteIndices));
|
runAndRefresh(requeueManyToTop(action.absoluteIndices));
|
||||||
exitEdit();
|
clearSelection();
|
||||||
}, [baseOffset, exitEdit, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]);
|
}, [baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]);
|
||||||
|
|
||||||
const groupRemove = useCallback(() => {
|
const groupRemove = useCallback(() => {
|
||||||
const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset);
|
const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset);
|
||||||
if (action.absoluteIndices.length === 0) {
|
if (action.absoluteIndices.length === 0) {
|
||||||
exitEdit();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOptimisticEntries(action.entriesWithoutSelected);
|
setOptimisticEntries(action.entriesWithoutSelected);
|
||||||
runAndRefresh(removeManyFromQueue(action.absoluteIndices, { updateMirror: false }));
|
runAndRefresh(removeManyFromQueue(action.absoluteIndices, { updateMirror: false }));
|
||||||
exitEdit();
|
clearSelection();
|
||||||
}, [baseOffset, exitEdit, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]);
|
}, [baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -631,7 +657,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
{listReady ? (
|
{listReady ? (
|
||||||
<FlashList
|
<FlashList
|
||||||
data={entries}
|
data={entries}
|
||||||
scrollEnabled={!editMode}
|
scrollEnabled
|
||||||
style={embedded ? embeddedListStyle : listClampStyle}
|
style={embedded ? embeddedListStyle : listClampStyle}
|
||||||
keyExtractor={(item) => item.key}
|
keyExtractor={(item) => item.key}
|
||||||
drawDistance={QUEUE_ROW_HEIGHT * 12}
|
drawDistance={QUEUE_ROW_HEIGHT * 12}
|
||||||
@@ -738,7 +764,6 @@ interface QueueRowProps {
|
|||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
makeDragGesture: (
|
makeDragGesture: (
|
||||||
localIndex: number,
|
|
||||||
longPress: boolean,
|
longPress: boolean,
|
||||||
entryKey: string,
|
entryKey: string,
|
||||||
entryCount: number
|
entryCount: number
|
||||||
@@ -784,8 +809,8 @@ const QueueRow = memo(function QueueRow({
|
|||||||
const artist = trackArtist(entry.track);
|
const artist = trackArtist(entry.track);
|
||||||
|
|
||||||
const gesture = useMemo(
|
const gesture = useMemo(
|
||||||
() => makeDragGesture(localIndex, !editMode, entryKey, entryCount),
|
() => makeDragGesture(!editMode, entryKey, entryCount),
|
||||||
[editMode, entryCount, entryKey, localIndex, makeDragGesture]
|
[editMode, entryCount, entryKey, makeDragGesture]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onJump = useCallback(() => onJumpIndex(localIndex), [localIndex, onJumpIndex]);
|
const onJump = useCallback(() => onJumpIndex(localIndex), [localIndex, onJumpIndex]);
|
||||||
@@ -800,7 +825,7 @@ const QueueRow = memo(function QueueRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rowMotionStyle = useAnimatedStyle(() => {
|
const rowMotionStyle = useAnimatedStyle(() => {
|
||||||
if (!dActive.value) {
|
if (!dActive.value || dStart.value < 0) {
|
||||||
return {
|
return {
|
||||||
transform: [
|
transform: [
|
||||||
{ translateY: withTiming(0, motion.quick) },
|
{ translateY: withTiming(0, motion.quick) },
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
indexQueueEntriesByKey,
|
||||||
|
moveQueueEntry,
|
||||||
removeQueueEntryAt,
|
removeQueueEntryAt,
|
||||||
resolveSelectedQueueAction,
|
resolveSelectedQueueAction,
|
||||||
type KeyedQueueEntry,
|
type KeyedQueueEntry,
|
||||||
@@ -14,6 +16,19 @@ function entryKeys(entries: readonly KeyedQueueEntry[]): string[] {
|
|||||||
return entries.map((entry) => entry.key);
|
return entries.map((entry) => entry.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 swipe remove before the optimistic mirror mutation', () => {
|
test('resolves swipe remove before the optimistic mirror mutation', () => {
|
||||||
const upcoming = keyed(['A', 'B', 'C']);
|
const upcoming = keyed(['A', 'B', 'C']);
|
||||||
const action = removeQueueEntryAt(upcoming, 1, 1);
|
const action = removeQueueEntryAt(upcoming, 1, 1);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export interface KeyedQueueEntry {
|
|||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QueueIndexByKey = Record<string, number>;
|
||||||
|
|
||||||
export interface QueueItemRemoveAction<T extends KeyedQueueEntry> {
|
export interface QueueItemRemoveAction<T extends KeyedQueueEntry> {
|
||||||
absoluteIndex: number;
|
absoluteIndex: number;
|
||||||
nextEntries: T[];
|
nextEntries: T[];
|
||||||
@@ -13,6 +15,33 @@ export interface SelectedQueueAction<T extends KeyedQueueEntry> {
|
|||||||
entriesWithoutSelected: 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>(
|
export function removeQueueEntryAt<T extends KeyedQueueEntry>(
|
||||||
entries: readonly T[],
|
entries: readonly T[],
|
||||||
localIndex: number,
|
localIndex: number,
|
||||||
|
|||||||
Reference in New Issue
Block a user