make queue faster

This commit is contained in:
Boof2015
2026-07-13 14:03:22 -04:00
parent d757e51255
commit 6a5bb90db0
+154 -119
View File
@@ -187,10 +187,20 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
const upcomingTotal = const upcomingTotal =
activeIndex >= 0 ? Math.max(0, tracks.length - activeIndex - 1) : tracks.length; activeIndex >= 0 ? Math.max(0, tracks.length - activeIndex - 1) : tracks.length;
const baseOffset = activeIndex >= 0 ? activeIndex + 1 : 0; const baseOffset = activeIndex >= 0 ? activeIndex + 1 : 0;
// Row callbacks resolve indices at call time from refs so their identities
// survive track advances — an index captured at render time would go stale.
const baseOffsetRef = useRef(baseOffset);
useEffect(() => {
baseOffsetRef.current = baseOffset;
}, [baseOffset]);
const entrySerial = useRef(0); // Built synchronously so a warm mirror paints on the list's first frame; the
const entriesRef = useRef<QueueEntry[]>([]); // update effect below takes over from there.
const [entries, setEntries] = useState<QueueEntry[]>([]); const [entries, setEntries] = useState<QueueEntry[]>(() =>
hasSnapshot ? reconcileQueueEntries(upcomingTracks, [], { current: 0 }) : []
);
const entrySerial = useRef(entries.length);
const entriesRef = useRef<QueueEntry[]>(entries);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set()); const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
@@ -201,6 +211,9 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
const dKey = useSharedValue(''); const dKey = useSharedValue('');
const dSettling = useSharedValue(false); const dSettling = useSharedValue(false);
const dIndexByKey = useSharedValue<QueueIndexByKey>({}); const dIndexByKey = useSharedValue<QueueIndexByKey>({});
// Entry count for the drag clamp, set at drag-arm — capturing it in the
// gesture closure instead forced a gesture rebuild on every queue change.
const dCount = useSharedValue(0);
// The index map is only read by worklets while a drag is active/settling, so // The index map is only read by worklets while a drag is active/settling, so
// it's maintained only inside that window — serializing a map with one entry // it's maintained only inside that window — serializing a map with one entry
// per queued track to the UI runtime on every queue change froze long queues. // per queued track to the UI runtime on every queue change froze long queues.
@@ -216,7 +229,8 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
target: SharedValue<number>, target: SharedValue<number>,
key: SharedValue<string>, key: SharedValue<string>,
settling: SharedValue<boolean>, settling: SharedValue<boolean>,
indexMap: SharedValue<QueueIndexByKey> indexMap: SharedValue<QueueIndexByKey>,
count: SharedValue<number>
) => { ) => {
'worklet'; 'worklet';
active.value = false; active.value = false;
@@ -226,9 +240,10 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
target.value = -1; target.value = -1;
key.value = ''; key.value = '';
indexMap.value = {}; indexMap.value = {};
count.value = 0;
} }
)(dActive, dTy, dStart, dTarget, dKey, dSettling, dIndexByKey); )(dActive, dTy, dStart, dTarget, dKey, dSettling, dIndexByKey, dCount);
}, [dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy]); }, [dActive, dCount, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy]);
const clearDragAfterReorderCommit = useCallback(() => { const clearDragAfterReorderCommit = useCallback(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -237,18 +252,28 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
}, [clearDragState]); }, [clearDragState]);
const updateDragIndexMap = useCallback( const updateDragIndexMap = useCallback(
(indexMap: QueueIndexByKey) => { (indexMap: QueueIndexByKey, count: number) => {
runOnUI((sharedIndexMap: SharedValue<QueueIndexByKey>, nextIndexMap: QueueIndexByKey) => { runOnUI(
'worklet'; (
sharedIndexMap.value = nextIndexMap; sharedIndexMap: SharedValue<QueueIndexByKey>,
})(dIndexByKey, indexMap); sharedCount: SharedValue<number>,
nextIndexMap: QueueIndexByKey,
nextCount: number
) => {
'worklet';
sharedIndexMap.value = nextIndexMap;
sharedCount.value = nextCount;
}
)(dIndexByKey, dCount, indexMap, count);
}, },
[dIndexByKey] [dCount, dIndexByKey]
); );
const setVisibleEntries = useCallback((nextEntries: QueueEntry[]) => { const setVisibleEntries = useCallback((nextEntries: QueueEntry[]) => {
entriesRef.current = nextEntries; entriesRef.current = nextEntries;
if (dragInFlightRef.current) updateDragIndexMap(indexQueueEntriesByKey(nextEntries)); if (dragInFlightRef.current) {
updateDragIndexMap(indexQueueEntriesByKey(nextEntries), nextEntries.length);
}
setEntries(nextEntries); setEntries(nextEntries);
}, [updateDragIndexMap]); }, [updateDragIndexMap]);
@@ -261,29 +286,26 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
); );
useEffect(() => { useEffect(() => {
let cancelled = false; setEntries((previous) => {
let frame: number | null = null; const next = hasSnapshot
? reconcileQueueEntries(
frame = requestAnimationFrame(() => { upcomingTracks,
if (cancelled) return; entriesRef.current.length > 0 ? entriesRef.current : previous,
setEntries((previous) => { entrySerial
const next = hasSnapshot )
? reconcileQueueEntries( : [];
upcomingTracks, // The mount-time reconcile of the synchronous initial state is a no-op;
entriesRef.current.length > 0 ? entriesRef.current : previous, // bail so it doesn't cost a render.
entrySerial const unchanged =
) next.length === previous.length &&
: []; next.every((entry, index) => entry === previous[index]);
entriesRef.current = next; const resolved = unchanged ? previous : next;
if (dragInFlightRef.current) updateDragIndexMap(indexQueueEntriesByKey(next)); entriesRef.current = resolved;
return next; if (dragInFlightRef.current) {
}); updateDragIndexMap(indexQueueEntriesByKey(resolved), resolved.length);
}
return resolved;
}); });
return () => {
cancelled = true;
if (frame != null) cancelAnimationFrame(frame);
};
}, [hasSnapshot, upcomingTracks, updateDragIndexMap]); }, [hasSnapshot, upcomingTracks, updateDragIndexMap]);
const visibleSelectedKeys = useMemo(() => { const visibleSelectedKeys = useMemo(() => {
@@ -322,18 +344,19 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
setVisibleEntries(nextEntries); setVisibleEntries(nextEntries);
clearDragAfterReorderCommit(); clearDragAfterReorderCommit();
commitNativeMove( commitNativeMove(
baseOffset + from, baseOffsetRef.current + from,
baseOffset + to, baseOffsetRef.current + to,
nextEntries.map((entry) => entry.track) nextEntries.map((entry) => entry.track)
); );
}, },
[baseOffset, clearDragAfterReorderCommit, clearDragState, commitNativeMove, setVisibleEntries] [clearDragAfterReorderCommit, clearDragState, commitNativeMove, setVisibleEntries]
); );
const onDragArm = useCallback( const onDragArm = useCallback(
(entryKey: string) => { (entryKey: string) => {
dragInFlightRef.current = true; dragInFlightRef.current = true;
const indexMap = indexQueueEntriesByKey(entriesRef.current); const snapshot = entriesRef.current;
const indexMap = indexQueueEntriesByKey(snapshot);
const currentIndex = indexMap[entryKey] ?? -1; const currentIndex = indexMap[entryKey] ?? -1;
runOnUI( runOnUI(
@@ -343,33 +366,50 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
start: SharedValue<number>, start: SharedValue<number>,
target: SharedValue<number>, target: SharedValue<number>,
sharedIndexMap: SharedValue<QueueIndexByKey>, sharedIndexMap: SharedValue<QueueIndexByKey>,
sharedCount: SharedValue<number>,
armedKey: string, armedKey: string,
armedIndex: number, armedIndex: number,
nextIndexMap: QueueIndexByKey nextIndexMap: QueueIndexByKey,
armedCount: number
) => { ) => {
'worklet'; 'worklet';
if (!active.value || key.value !== armedKey) return; if (!active.value || key.value !== armedKey) return;
sharedIndexMap.value = nextIndexMap; sharedIndexMap.value = nextIndexMap;
sharedCount.value = armedCount;
start.value = armedIndex; start.value = armedIndex;
target.value = armedIndex; target.value = armedIndex;
} }
)(dActive, dKey, dStart, dTarget, dIndexByKey, entryKey, currentIndex, indexMap); )(
dActive,
dKey,
dStart,
dTarget,
dIndexByKey,
dCount,
entryKey,
currentIndex,
indexMap,
snapshot.length
);
playHaptic('queueLift'); playHaptic('queueLift');
}, },
[dActive, dIndexByKey, dKey, dStart, dTarget] [dActive, dCount, dIndexByKey, dKey, dStart, dTarget]
); );
const onDragAbort = useCallback(() => { const onDragAbort = useCallback(() => {
dragInFlightRef.current = false; dragInFlightRef.current = false;
}, []); }, []);
// Depends only on the entry key: entry count and indices are resolved at
// drag time through shared values / refs, so row gestures survive every
// queue mutation and rebuild only when FlashList recycles a row.
const makeDragGesture = useCallback( const makeDragGesture = useCallback(
( (entryKey: string): GestureType =>
longPress: boolean, Gesture.Pan()
entryKey: string, // Vertical-only so a horizontal swipe starting on the handle still
entryCount: number // falls through to the row's SwipeableRow pan.
): GestureType => { .activeOffsetY([-2, 2])
const gesture = Gesture.Pan() .failOffsetX([-14, 14])
.onStart(() => { .onStart(() => {
dStart.value = -1; dStart.value = -1;
dTarget.value = -1; dTarget.value = -1;
@@ -384,7 +424,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
if (dStart.value < 0) return; 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 dCount.value
); );
if (nextTarget !== dTarget.value) { if (nextTarget !== dTarget.value) {
dTarget.value = nextTarget; dTarget.value = nextTarget;
@@ -425,11 +465,20 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
dKey.value = ''; dKey.value = '';
dIndexByKey.value = {}; dIndexByKey.value = {};
runOnJS(onDragAbort)(); runOnJS(onDragAbort)();
}); }),
[
return longPress ? gesture.activateAfterLongPress(250) : gesture.minDistance(1); dActive,
}, dCount,
[dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy, finishDrag, onDragAbort, onDragArm] dIndexByKey,
dKey,
dSettling,
dStart,
dTarget,
dTy,
finishDrag,
onDragAbort,
onDragArm,
]
); );
const runAndRefresh = useCallback( const runAndRefresh = useCallback(
@@ -440,30 +489,36 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
); );
const jump = useCallback( const jump = useCallback(
(localIndex: number) => { (key: string) => {
runAndRefresh(jumpToQueueIndex(baseOffset + localIndex)); const localIndex = entriesRef.current.findIndex((entry) => entry.key === key);
if (localIndex < 0) return;
runAndRefresh(jumpToQueueIndex(baseOffsetRef.current + localIndex));
}, },
[baseOffset, runAndRefresh] [runAndRefresh]
); );
const playNext = useCallback( const playNext = useCallback(
(localIndex: number) => { (key: string) => {
const localIndex = entriesRef.current.findIndex((entry) => entry.key === key);
if (localIndex < 0) return;
const nextEntries = moveQueueEntry(entriesRef.current, localIndex, 0); const nextEntries = moveQueueEntry(entriesRef.current, localIndex, 0);
setOptimisticEntries(nextEntries); setOptimisticEntries(nextEntries);
runAndRefresh(requeueToTop(baseOffset + localIndex)); runAndRefresh(requeueToTop(baseOffsetRef.current + localIndex));
}, },
[baseOffset, runAndRefresh, setOptimisticEntries] [runAndRefresh, setOptimisticEntries]
); );
const remove = useCallback( const remove = useCallback(
(localIndex: number) => { (key: string) => {
const action = removeQueueEntryAt(entriesRef.current, localIndex, baseOffset); const localIndex = entriesRef.current.findIndex((entry) => entry.key === key);
if (localIndex < 0) return;
const action = removeQueueEntryAt(entriesRef.current, localIndex, baseOffsetRef.current);
if (!action) return; if (!action) return;
setOptimisticEntries(action.nextEntries); setOptimisticEntries(action.nextEntries);
runAndRefresh(removeFromQueue(action.absoluteIndex, { updateMirror: false })); runAndRefresh(removeFromQueue(action.absoluteIndex, { updateMirror: false }));
}, },
[baseOffset, runAndRefresh, setOptimisticEntries] [runAndRefresh, setOptimisticEntries]
); );
const toggleSelect = useCallback((key: string) => { const toggleSelect = useCallback((key: string) => {
@@ -534,11 +589,9 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
); );
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: ListRenderItemInfo<QueueEntry>) => ( ({ item }: ListRenderItemInfo<QueueEntry>) => (
<QueueRow <QueueRow
entry={item} entry={item}
entryCount={entries.length}
localIndex={index}
actionsEnabled={queueReady} actionsEnabled={queueReady}
editMode={editMode} editMode={editMode}
selected={visibleSelectedKeys.has(item.key)} selected={visibleSelectedKeys.has(item.key)}
@@ -550,9 +603,9 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
dKey={dKey} dKey={dKey}
dSettling={dSettling} dSettling={dSettling}
dIndexByKey={dIndexByKey} dIndexByKey={dIndexByKey}
onJumpIndex={jump} onJumpKey={jump}
onPlayNextIndex={playNext} onPlayNextKey={playNext}
onRemoveIndex={remove} onRemoveKey={remove}
onToggleSelectKey={toggleSelect} onToggleSelectKey={toggleSelect}
/> />
), ),
@@ -565,7 +618,6 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
dTarget, dTarget,
dTy, dTy,
editMode, editMode,
entries.length,
jump, jump,
makeDragGesture, makeDragGesture,
playNext, playNext,
@@ -758,16 +810,10 @@ const Artwork = memo(function Artwork({ uri, title }: { uri?: string; title?: st
interface QueueRowProps { interface QueueRowProps {
entry: QueueEntry; entry: QueueEntry;
entryCount: number;
localIndex: number;
actionsEnabled: boolean; actionsEnabled: boolean;
editMode: boolean; editMode: boolean;
selected: boolean; selected: boolean;
makeDragGesture: ( makeDragGesture: (entryKey: string) => GestureType;
longPress: boolean,
entryKey: string,
entryCount: number
) => GestureType;
dStart: SharedValue<number>; dStart: SharedValue<number>;
dTarget: SharedValue<number>; dTarget: SharedValue<number>;
dTy: SharedValue<number>; dTy: SharedValue<number>;
@@ -775,16 +821,14 @@ interface QueueRowProps {
dKey: SharedValue<string>; dKey: SharedValue<string>;
dSettling: SharedValue<boolean>; dSettling: SharedValue<boolean>;
dIndexByKey: SharedValue<QueueIndexByKey>; dIndexByKey: SharedValue<QueueIndexByKey>;
onJumpIndex: (localIndex: number) => void; onJumpKey: (key: string) => void;
onPlayNextIndex: (localIndex: number) => void; onPlayNextKey: (key: string) => void;
onRemoveIndex: (localIndex: number) => void; onRemoveKey: (key: string) => void;
onToggleSelectKey: (key: string) => void; onToggleSelectKey: (key: string) => void;
} }
const QueueRow = memo(function QueueRow({ const QueueRow = memo(function QueueRow({
entry, entry,
entryCount,
localIndex,
actionsEnabled, actionsEnabled,
editMode, editMode,
selected, selected,
@@ -796,9 +840,9 @@ const QueueRow = memo(function QueueRow({
dKey, dKey,
dSettling, dSettling,
dIndexByKey, dIndexByKey,
onJumpIndex, onJumpKey,
onPlayNextIndex, onPlayNextKey,
onRemoveIndex, onRemoveKey,
onToggleSelectKey, onToggleSelectKey,
}: QueueRowProps) { }: QueueRowProps) {
const styles = useStyles(); const styles = useStyles();
@@ -808,38 +852,35 @@ const QueueRow = memo(function QueueRow({
const title = trackTitle(entry.track); const title = trackTitle(entry.track);
const artist = trackArtist(entry.track); const artist = trackArtist(entry.track);
const gesture = useMemo( const gesture = useMemo(() => makeDragGesture(entryKey), [entryKey, makeDragGesture]);
() => makeDragGesture(!editMode, entryKey, entryCount),
[editMode, entryCount, entryKey, makeDragGesture]
);
const onJump = useCallback(() => onJumpIndex(localIndex), [localIndex, onJumpIndex]); const onJump = useCallback(() => onJumpKey(entryKey), [entryKey, onJumpKey]);
const onPlayNext = useCallback( const onPlayNext = useCallback(() => onPlayNextKey(entryKey), [entryKey, onPlayNextKey]);
() => onPlayNextIndex(localIndex), const onRemove = useCallback(() => onRemoveKey(entryKey), [entryKey, onRemoveKey]);
[localIndex, onPlayNextIndex]
);
const onRemove = useCallback(() => onRemoveIndex(localIndex), [localIndex, onRemoveIndex]);
const onToggleSelect = useCallback( const onToggleSelect = useCallback(
() => onToggleSelectKey(entryKey), () => onToggleSelectKey(entryKey),
[entryKey, onToggleSelectKey] [entryKey, onToggleSelectKey]
); );
// Idle rows must stay near-free: the early-exit branch creates no timing
// animations, so scroll-time evaluations (mount/recycle) cost almost nothing.
// Row indices come from dIndexByKey, which is only populated while a drag is
// in flight — the idle branch never needs them.
const rowMotionStyle = useAnimatedStyle(() => { const rowMotionStyle = useAnimatedStyle(() => {
if (!dActive.value || dStart.value < 0) { if (!dActive.value || dStart.value < 0) {
return { return {
transform: [ transform: [{ translateY: 0 }, { scale: 1 }],
{ translateY: withTiming(0, motion.quick) },
{ scale: withTiming(1, motion.quick) },
],
zIndex: 0, zIndex: 0,
elevation: 0, elevation: 0,
shadowOpacity: withTiming(0, motion.quick), shadowOpacity: 0,
}; };
} }
const currentIndex = dIndexByKey.value[entryKey] ?? -1;
if (dKey.value === entryKey) { if (dKey.value === entryKey) {
const currentIndex = dIndexByKey.value[entryKey] ?? localIndex; const baseIndexDelta =
const baseIndexDelta = (currentIndex - dStart.value) * QUEUE_ROW_HEIGHT; currentIndex < 0 ? 0 : (currentIndex - dStart.value) * QUEUE_ROW_HEIGHT;
return { return {
transform: [ transform: [
{ translateY: dTy.value - baseIndexDelta }, { translateY: dTy.value - baseIndexDelta },
@@ -851,36 +892,31 @@ const QueueRow = memo(function QueueRow({
}; };
} }
const currentIndex = dIndexByKey.value[entryKey] ?? localIndex;
if (dSettling.value) { if (dSettling.value) {
return { return {
transform: [ transform: [{ translateY: withTiming(0, motion.quick) }, { scale: 1 }],
{ translateY: withTiming(0, motion.quick) },
{ scale: withTiming(1, motion.quick) },
],
zIndex: 0, zIndex: 0,
elevation: 0, elevation: 0,
shadowOpacity: withTiming(0, motion.quick), shadowOpacity: 0,
}; };
} }
const start = dStart.value; const start = dStart.value;
const target = dTarget.value; const target = dTarget.value;
let shift = 0; let shift = 0;
if (start < target && currentIndex > start && currentIndex <= target) { if (currentIndex >= 0) {
shift = -QUEUE_ROW_HEIGHT; if (start < target && currentIndex > start && currentIndex <= target) {
} else if (start > target && currentIndex >= target && currentIndex < start) { shift = -QUEUE_ROW_HEIGHT;
shift = QUEUE_ROW_HEIGHT; } else if (start > target && currentIndex >= target && currentIndex < start) {
shift = QUEUE_ROW_HEIGHT;
}
} }
return { return {
transform: [ transform: [{ translateY: withTiming(shift, motion.quick) }, { scale: 1 }],
{ translateY: withTiming(shift, motion.quick) },
{ scale: withTiming(1, motion.quick) },
],
zIndex: 0, zIndex: 0,
elevation: 0, elevation: 0,
shadowOpacity: withTiming(0, motion.quick), shadowOpacity: 0,
}; };
}); });
@@ -925,7 +961,7 @@ const QueueRow = memo(function QueueRow({
{artist} {artist}
</Text> </Text>
</View> </View>
{editMode ? ( {actionsEnabled ? (
<GestureDetector gesture={gesture}> <GestureDetector gesture={gesture}>
<View <View
style={styles.dragHandle} style={styles.dragHandle}
@@ -976,7 +1012,6 @@ const QueueRow = memo(function QueueRow({
return ( return (
<Animated.View style={[styles.rowOuter, rowMotionStyle]}> <Animated.View style={[styles.rowOuter, rowMotionStyle]}>
<SwipeableRow <SwipeableRow
dragGesture={gesture}
swipeRight={{ icon: 'play-skip-forward', color: colors.accent, onCommit: onPlayNext }} swipeRight={{ icon: 'play-skip-forward', color: colors.accent, onCommit: onPlayNext }}
swipeLeft={{ icon: 'trash', color: colors.warning, onCommit: onRemove }} swipeLeft={{ icon: 'trash', color: colors.warning, onCommit: onRemove }}
> >