mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 12:40:15 +02:00
fix queue bugs
This commit is contained in:
@@ -21,16 +21,14 @@ import Animated, {
|
|||||||
import { useColors } from '@/theme/themed';
|
import { useColors } from '@/theme/themed';
|
||||||
import { motion } from '@/theme/motion';
|
import { motion } from '@/theme/motion';
|
||||||
import { playHaptic } from '@/lib/haptics';
|
import { playHaptic } from '@/lib/haptics';
|
||||||
import { swipeLaneOpacity } from '@/components/swipeableRowState';
|
import {
|
||||||
|
SWIPE_ACTIVE_OFFSET_X,
|
||||||
|
SWIPE_FAIL_OFFSET_Y,
|
||||||
|
swipeLaneOpacity,
|
||||||
|
} from '@/components/swipeableRowState';
|
||||||
|
|
||||||
type IconName = keyof typeof Ionicons.glyphMap;
|
type IconName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
const SWIPE_ACTIVE_OFFSET_X = 10;
|
|
||||||
// Scroll-slop-sized: at 30 every vertical drag starting on a row had to travel
|
|
||||||
// 30px before the pan failed and the surrounding scrollable could win. Keep
|
|
||||||
// this tighter than the horizontal activation threshold so vertical intent
|
|
||||||
// yields immediately, especially inside the queue's BottomSheet scrollable.
|
|
||||||
const SWIPE_FAIL_OFFSET_Y = 6;
|
|
||||||
// A fixed reveal distance avoids an onLayout -> setState -> gesture rebuild for
|
// A fixed reveal distance avoids an onLayout -> setState -> gesture rebuild for
|
||||||
// every recycled list row. It is also more predictable on wide tablet rows than
|
// every recycled list row. It is also more predictable on wide tablet rows than
|
||||||
// using half of the full row width.
|
// using half of the full row width.
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ import type { Track as RntpTrack } from 'react-native-track-player';
|
|||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { AstraLogo } from '@/components/AstraLogo';
|
import { AstraLogo } from '@/components/AstraLogo';
|
||||||
import { SwipeableRow } from '@/components/SwipeableRow';
|
import { SwipeableRow } from '@/components/SwipeableRow';
|
||||||
|
import {
|
||||||
|
SWIPE_ACTIVE_OFFSET_X,
|
||||||
|
SWIPE_FAIL_OFFSET_Y,
|
||||||
|
} from '@/components/swipeableRowState';
|
||||||
import {
|
import {
|
||||||
radius,
|
radius,
|
||||||
spacing,
|
spacing,
|
||||||
@@ -621,11 +625,13 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
|
|
||||||
const playNext = useCallback(
|
const playNext = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
const localIndex = entriesRef.current.findIndex((entry) => entry.key === key);
|
const snapshot = entriesRef.current;
|
||||||
|
const localIndex = snapshot.findIndex((entry) => entry.key === key);
|
||||||
if (localIndex < 0) return;
|
if (localIndex < 0) return;
|
||||||
const nextEntries = moveQueueEntry(entriesRef.current, localIndex, 0);
|
const absoluteIndex = snapshot[localIndex].absoluteIndex;
|
||||||
|
const nextEntries = moveQueueEntry(snapshot, localIndex, 0);
|
||||||
setOptimisticEntries(nextEntries);
|
setOptimisticEntries(nextEntries);
|
||||||
runAndRefresh(requeueToTop(entriesRef.current[localIndex].absoluteIndex, {
|
runAndRefresh(requeueToTop(absoluteIndex, {
|
||||||
virtualPosition: virtualMode,
|
virtualPosition: virtualMode,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -634,14 +640,15 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
|
|
||||||
const remove = useCallback(
|
const remove = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
const localIndex = entriesRef.current.findIndex((entry) => entry.key === key);
|
const snapshot = entriesRef.current;
|
||||||
|
const localIndex = snapshot.findIndex((entry) => entry.key === key);
|
||||||
if (localIndex < 0) return;
|
if (localIndex < 0) return;
|
||||||
const action = removeQueueEntryAt(entriesRef.current, localIndex, baseOffsetRef.current);
|
const action = removeQueueEntryAt(snapshot, localIndex, baseOffsetRef.current);
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|
||||||
setOptimisticEntries(action.nextEntries);
|
setOptimisticEntries(action.nextEntries);
|
||||||
runAndRefresh(removeFromQueue(
|
runAndRefresh(removeFromQueue(
|
||||||
entriesRef.current[localIndex].absoluteIndex,
|
action.absoluteIndex,
|
||||||
{ updateMirror: false, virtualPosition: virtualMode },
|
{ updateMirror: false, virtualPosition: virtualMode },
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
@@ -963,6 +970,10 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
enableContentPanningGesture={!editMode}
|
enableContentPanningGesture={!editMode}
|
||||||
enableHandlePanningGesture
|
enableHandlePanningGesture
|
||||||
|
// Mirror SwipeableRow's axis lock so the sheet yields to an intentional
|
||||||
|
// horizontal row swipe while vertical movement still scrolls immediately.
|
||||||
|
activeOffsetY={[-SWIPE_FAIL_OFFSET_Y, SWIPE_FAIL_OFFSET_Y]}
|
||||||
|
failOffsetX={[-SWIPE_ACTIVE_OFFSET_X, SWIPE_ACTIVE_OFFSET_X]}
|
||||||
onChange={onSheetChange}
|
onChange={onSheetChange}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ function entryKeys(entries: readonly KeyedQueueEntry[]): string[] {
|
|||||||
return entries.map((entry) => entry.key);
|
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', () => {
|
test('resolves each drag from the latest keyed queue order', () => {
|
||||||
let upcoming = keyed(['A', 'B', 'C', 'D']);
|
let upcoming = keyed(['A', 'B', 'C', 'D']);
|
||||||
|
|
||||||
@@ -29,16 +37,81 @@ test('resolves each drag from the latest keyed queue order', () => {
|
|||||||
assert.deepEqual(entryKeys(upcoming), ['A', 'B', 'C', 'D']);
|
assert.deepEqual(entryKeys(upcoming), ['A', 'B', 'C', 'D']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolves swipe remove before the optimistic mirror mutation', () => {
|
test('resolves first, middle, and last swipe removals from the pre-mutation order', () => {
|
||||||
const upcoming = keyed(['A', 'B', 'C']);
|
const scenarios = [
|
||||||
const action = removeQueueEntryAt(upcoming, 1, 1);
|
{ 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');
|
if (!action) throw new Error('expected a remove action');
|
||||||
assert.equal(action.absoluteIndex, 2);
|
entriesRef = action.nextEntries;
|
||||||
assert.deepEqual(entryKeys(action.nextEntries), ['A', 'C']);
|
|
||||||
|
|
||||||
const mirrorAfterNativeCompletionWithSkippedUpdate = action.nextEntries;
|
assert.equal(action.absoluteIndex, 2);
|
||||||
assert.deepEqual(entryKeys(mirrorAfterNativeCompletionWithSkippedUpdate), ['A', 'C']);
|
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', () => {
|
test('captures selected absolute indices before optimistic batch mutation', () => {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
export type SwipeLaneSide = 'left' | 'right';
|
export type SwipeLaneSide = 'left' | 'right';
|
||||||
|
|
||||||
|
export const SWIPE_ACTIVE_OFFSET_X = 10;
|
||||||
|
// Scroll-slop-sized: at 30 every vertical drag starting on a row had to travel
|
||||||
|
// 30px before the pan failed and the surrounding scrollable could win. Keep
|
||||||
|
// this tighter than the horizontal activation threshold so vertical intent
|
||||||
|
// yields immediately.
|
||||||
|
export const SWIPE_FAIL_OFFSET_Y = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action lanes sit underneath an opaque row and should only exist visually
|
* Action lanes sit underneath an opaque row and should only exist visually
|
||||||
* while that row is moving toward them. Keeping them opaque at rest lets a
|
* while that row is moving toward them. Keeping them opaque at rest lets a
|
||||||
|
|||||||
Reference in New Issue
Block a user