From a485bdacd12622eb3bdf397abf4c44df81aa3922 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:33:48 -0400 Subject: [PATCH] fix queue bug --- src/components/queue/QueueTray.tsx | 119 +++++++++++++-------- src/components/queue/queueActions.test.mts | 15 +++ src/components/queue/queueActions.ts | 29 +++++ 3 files changed, 116 insertions(+), 47 deletions(-) diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index 98b8010..4132f77 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -59,8 +59,11 @@ import { } from '@/audio/playbackController'; import { useQueue } from './useQueue'; import { + indexQueueEntriesByKey, + moveQueueEntry, removeQueueEntryAt, resolveSelectedQueueAction, + type QueueIndexByKey, } from './queueActions'; const QUEUE_ROW_HEIGHT = 64; @@ -73,8 +76,6 @@ interface QueueEntry { track: RntpTrack; } -type QueueIndexByKey = Record; - function rntpKey(track: RntpTrack): string { return String(track.id ?? track.url); } @@ -99,21 +100,6 @@ function queueCountLabel(count: number): string { return `${count} songs next`; } -function arrayMove(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 { 'worklet'; 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[]) => { entriesRef.current = nextEntries; - if (dragInFlightRef.current) updateDragIndexMap(indexByEntryKey(nextEntries)); + if (dragInFlightRef.current) updateDragIndexMap(indexQueueEntriesByKey(nextEntries)); setEntries(nextEntries); }, [updateDragIndexMap]); @@ -280,9 +266,6 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: frame = requestAnimationFrame(() => { if (cancelled) return; - setEditMode(false); - setSelectedKeys(new Set()); - setEntries((previous) => { const next = hasSnapshot ? reconcileQueueEntries( @@ -292,7 +275,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: ) : []; entriesRef.current = next; - if (dragInFlightRef.current) updateDragIndexMap(indexByEntryKey(next)); + if (dragInFlightRef.current) updateDragIndexMap(indexQueueEntriesByKey(next)); return next; }); }); @@ -301,7 +284,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: cancelled = true; if (frame != null) cancelAnimationFrame(frame); }; - }, [hasSnapshot, setVisibleEntries, upcomingTracks, updateDragIndexMap]); + }, [hasSnapshot, upcomingTracks, updateDragIndexMap]); const visibleSelectedKeys = useMemo(() => { 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) => { const snapshot = entriesRef.current; if (from === to || from < 0 || to < 0 || from >= snapshot.length || to >= snapshot.length) { + clearDragState(); return; } - const nextEntries = arrayMove(snapshot, from, to); + const nextEntries = moveQueueEntry(snapshot, from, to); playHaptic('queueDrop'); setVisibleEntries(nextEntries); clearDragAfterReorderCommit(); @@ -343,13 +327,37 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: nextEntries.map((entry) => entry.track) ); }, - [baseOffset, clearDragAfterReorderCommit, commitNativeMove, setVisibleEntries] + [baseOffset, clearDragAfterReorderCommit, clearDragState, commitNativeMove, setVisibleEntries] ); - const onDragArm = useCallback(() => { - dragInFlightRef.current = true; - playHaptic('queueLift'); - }, []); + const onDragArm = useCallback( + (entryKey: string) => { + dragInFlightRef.current = true; + const indexMap = indexQueueEntriesByKey(entriesRef.current); + const currentIndex = indexMap[entryKey] ?? -1; + + runOnUI( + ( + active: SharedValue, + key: SharedValue, + start: SharedValue, + target: SharedValue, + sharedIndexMap: SharedValue, + 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(() => { dragInFlightRef.current = false; @@ -357,24 +365,23 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: const makeDragGesture = useCallback( ( - localIndex: number, longPress: boolean, entryKey: string, entryCount: number ): GestureType => { const gesture = Gesture.Pan() .onStart(() => { - const currentIndex = dIndexByKey.value[entryKey] ?? localIndex; - dStart.value = currentIndex; - dTarget.value = currentIndex; + dStart.value = -1; + dTarget.value = -1; dTy.value = 0; dKey.value = entryKey; dSettling.value = false; dActive.value = true; - runOnJS(onDragArm)(); + runOnJS(onDragArm)(entryKey); }) .onUpdate((event) => { dTy.value = event.translationY; + if (dStart.value < 0) return; const nextTarget = clampLocal( Math.round(dStart.value + event.translationY / QUEUE_ROW_HEIGHT), entryCount @@ -407,9 +414,20 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: dSettling.value = true; 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] ); @@ -430,7 +448,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: const playNext = useCallback( (localIndex: number) => { - const nextEntries = arrayMove(entriesRef.current, localIndex, 0); + const nextEntries = moveQueueEntry(entriesRef.current, localIndex, 0); setOptimisticEntries(nextEntries); runAndRefresh(requeueToTop(baseOffset + localIndex)); }, @@ -462,29 +480,37 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: 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 action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset); if (action.absoluteIndices.length === 0) { - exitEdit(); + clearSelection(); return; } setOptimisticEntries(action.entriesWithSelectedFirst); runAndRefresh(requeueManyToTop(action.absoluteIndices)); - exitEdit(); - }, [baseOffset, exitEdit, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]); + clearSelection(); + }, [baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]); const groupRemove = useCallback(() => { const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset); if (action.absoluteIndices.length === 0) { - exitEdit(); + clearSelection(); return; } setOptimisticEntries(action.entriesWithoutSelected); runAndRefresh(removeManyFromQueue(action.absoluteIndices, { updateMirror: false })); - exitEdit(); - }, [baseOffset, exitEdit, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]); + clearSelection(); + }, [baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( @@ -631,7 +657,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: {listReady ? ( item.key} drawDistance={QUEUE_ROW_HEIGHT * 12} @@ -738,7 +764,6 @@ interface QueueRowProps { editMode: boolean; selected: boolean; makeDragGesture: ( - localIndex: number, longPress: boolean, entryKey: string, entryCount: number @@ -784,8 +809,8 @@ const QueueRow = memo(function QueueRow({ const artist = trackArtist(entry.track); const gesture = useMemo( - () => makeDragGesture(localIndex, !editMode, entryKey, entryCount), - [editMode, entryCount, entryKey, localIndex, makeDragGesture] + () => makeDragGesture(!editMode, entryKey, entryCount), + [editMode, entryCount, entryKey, makeDragGesture] ); const onJump = useCallback(() => onJumpIndex(localIndex), [localIndex, onJumpIndex]); @@ -800,7 +825,7 @@ const QueueRow = memo(function QueueRow({ ); const rowMotionStyle = useAnimatedStyle(() => { - if (!dActive.value) { + if (!dActive.value || dStart.value < 0) { return { transform: [ { translateY: withTiming(0, motion.quick) }, diff --git a/src/components/queue/queueActions.test.mts b/src/components/queue/queueActions.test.mts index 54c8b23..14e2615 100644 --- a/src/components/queue/queueActions.test.mts +++ b/src/components/queue/queueActions.test.mts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + indexQueueEntriesByKey, + moveQueueEntry, removeQueueEntryAt, resolveSelectedQueueAction, type KeyedQueueEntry, @@ -14,6 +16,19 @@ function entryKeys(entries: readonly KeyedQueueEntry[]): string[] { 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', () => { const upcoming = keyed(['A', 'B', 'C']); const action = removeQueueEntryAt(upcoming, 1, 1); diff --git a/src/components/queue/queueActions.ts b/src/components/queue/queueActions.ts index 5a3752f..1a9ea04 100644 --- a/src/components/queue/queueActions.ts +++ b/src/components/queue/queueActions.ts @@ -2,6 +2,8 @@ export interface KeyedQueueEntry { key: string; } +export type QueueIndexByKey = Record; + export interface QueueItemRemoveAction { absoluteIndex: number; nextEntries: T[]; @@ -13,6 +15,33 @@ export interface SelectedQueueAction { entriesWithoutSelected: T[]; } +export function indexQueueEntriesByKey( + entries: readonly T[] +): QueueIndexByKey { + const out: QueueIndexByKey = {}; + entries.forEach((entry, index) => { + out[entry.key] = index; + }); + return out; +} + +export function moveQueueEntry(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( entries: readonly T[], localIndex: number,