import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, StyleSheet, View, useWindowDimensions } from 'react-native'; import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import BottomSheet, { BottomSheetBackdrop, type BottomSheetBackdropProps, useBottomSheetScrollableCreator } from '@gorhom/bottom-sheet'; import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list'; import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'; import Animated, { runOnJS, runOnUI, useAnimatedStyle, useSharedValue, withTiming, type SharedValue } from 'react-native-reanimated'; import type { Track as RntpTrack } from 'react-native-track-player'; import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { SwipeableRow } from '@/components/SwipeableRow'; import { radius, spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { motion } from '@/theme/motion'; import { artworkThumbFromSource } from '@/library/artwork'; import { playHaptic } from '@/lib/haptics'; import { useQueueStore } from '@/stores/queueStore'; import { getVirtualQueuePage, getVirtualQueueState, jumpToQueueIndex, moveQueueItem, removeFromQueue, removeManyFromQueue, requeueManyToTop, requeueToTop, setUpcoming } from '@/audio/playbackController'; import { useQueue } from './useQueue'; import { indexQueueEntriesByKey, moveQueueEntry, removeQueueEntryAt, resolveSelectedQueueAction, type QueueIndexByKey, } from './queueActions'; const QUEUE_ROW_HEIGHT = 64; const ART = 42; const EMPTY_KEY_SET = new Set(); interface QueueEntry { key: string; identity: string; track: RntpTrack; absoluteIndex: number; } function rntpKey(track: RntpTrack): string { return String(track.id ?? track.url); } function trackTitle(track: RntpTrack): string { return track.title?.trim() || 'Unknown title'; } function trackArtist(track: RntpTrack): string { return track.artist?.trim() || 'Unknown artist'; } function artworkUri(track: RntpTrack): string | undefined { // RNTP tracks carry the full-size cover; 42px rows want the generated thumb. if (typeof track.artwork !== 'string') return undefined; return artworkThumbFromSource(track.artwork) ?? undefined; } function queueCountLabel(count: number): string { if (count === 0) return 'No songs next'; if (count === 1) return '1 song next'; return `${count} songs next`; } function clampLocal(value: number, len: number): number { 'worklet'; return Math.max(0, Math.min(len - 1, value)); } function reconcileQueueEntries( tracks: readonly RntpTrack[], previous: readonly QueueEntry[], nextSerial: { current: number }, baseOffset: number, ): QueueEntry[] { const available = new Map(); previous.forEach((entry) => { const bucket = available.get(entry.identity); if (bucket) bucket.push(entry); else available.set(entry.identity, [entry]); }); return tracks.map((track, index) => { const identity = rntpKey(track); const nativePosition = track.astraQueuePosition; const absoluteIndex = typeof nativePosition === 'number' ? nativePosition : baseOffset + index; const reused = available.get(identity)?.shift(); if (reused) { // Same track object → same entry object, so memo'd rows bail out when // only other parts of the queue changed (e.g. a track advance). if (reused.track === track && reused.absoluteIndex === absoluteIndex) return reused; return { ...reused, track, identity, absoluteIndex }; } const key = `${identity}:${nextSerial.current}`; nextSerial.current += 1; return { key, identity, track, absoluteIndex }; }); } interface QueueTrayProps { onClose: () => void; embedded?: boolean; } // memo: the parent now-playing screen re-renders on store changes; the tray's // ~15-hook body shouldn't re-execute unless its own inputs change. export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: QueueTrayProps) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const snapPoints = useMemo(() => ['58%', '100%'], []); const renderFlashListScrollComponent = useBottomSheetScrollableCreator(); // While the sheet-open animation runs, the bottom-sheet scrollable can // momentarily fail to constrain the list, so FlashList measures its viewport // as the full CONTENT height (~100k dp for a long queue), believes every row // is visible, and mounts thousands of views — a multi-second main-thread // freeze. Clamping the list area to the window height caps the viewport no // matter what the sheet reports; both snap points stay unaffected. The flex // matches FlashList's own root default, so the wrapper reproduces the exact // box the list had when the clamp sat on it directly. const listAreaStyle = useMemo( () => [styles.listArea, { maxHeight: windowHeight }], [styles, windowHeight] ); // Same bug, milder symptom: a viewport measured during the open animation can // stick at the clamp height (taller than the sheet's real content area), which // silently shortens the scroll range — the last few rows become unreachable. // Mounting the list only after the sheet settles removes the bad window. const [listReady, setListReady] = useState(embedded); const onSheetChange = useCallback((index: number) => { if (index >= 0) setListReady(true); }, []); // The gate leaves the list area blank while the sheet opens (plus FlashList's // first-layout frame), which reads as content popping in. A static, // pixel-identical preview of the first screenful of rows fills the slot from // the tray's first frame and unmounts once the real list has painted (onLoad // fires a frame after first layout completes, so rows are already underneath). const [listPainted, setListPainted] = useState(false); const onListLoad = useCallback(() => setListPainted(true), []); const previewCount = Math.ceil(windowHeight / QUEUE_ROW_HEIGHT); // Bottom padding clears the gesture-nav inset so the last row is fully // scrollable into view at the 100% snap. const listContentStyle = useMemo( () => [styles.listContent, { paddingBottom: spacing.xxl + insets.bottom }], [styles, insets.bottom] ); const listContentEditStyle = useMemo( () => [styles.listContent, { paddingBottom: spacing.xxl * 2 + insets.bottom }], [styles, insets.bottom] ); const { tracks, activeIndex, hasSnapshot, refresh } = useQueue(true); const currentTrack = activeIndex >= 0 ? tracks[activeIndex] : undefined; const rollingUpcomingTracks = useMemo( () => (activeIndex >= 0 ? tracks.slice(activeIndex + 1) : tracks), [tracks, activeIndex] ); const virtualState = getVirtualQueueState(); const virtualMode = virtualState !== null; const virtualActivePosition = virtualState?.activePosition ?? -1; const [virtualTracks, setVirtualTracks] = useState([]); const virtualTracksRef = useRef([]); const virtualLoadGeneration = useRef(0); const virtualLoading = useRef(false); const loadVirtualPage = useCallback(async (reset: boolean) => { const state = getVirtualQueueState(); if (!state || (!reset && virtualLoading.current)) return; const generation = reset ? ++virtualLoadGeneration.current : virtualLoadGeneration.current; const existing = reset ? [] : virtualTracksRef.current; const lastPosition = existing.length > 0 ? existing[existing.length - 1].astraQueuePosition : state.activePosition; const start = typeof lastPosition === 'number' ? lastPosition + 1 : state.activePosition + 1; if (start >= state.totalCount) { if (reset) { virtualLoading.current = false; virtualTracksRef.current = []; setVirtualTracks([]); } return; } virtualLoading.current = true; try { const page = await getVirtualQueuePage(start, 100); if ( !page || generation !== virtualLoadGeneration.current || getVirtualQueueState()?.sessionId !== state.sessionId ) return; const existingPositions = new Set( existing.map((track) => track.astraQueuePosition).filter( (position): position is number => typeof position === 'number' ) ); const incoming = page.items .filter((item) => item.queuePosition >= start && !existingPositions.has(item.queuePosition)) .map((item) => item.track); const next = reset ? incoming : [...existing, ...incoming]; // Keep no more than five tray pages in JS. const bounded = next.slice(-500); virtualTracksRef.current = bounded; setVirtualTracks(bounded); } finally { if (generation === virtualLoadGeneration.current) virtualLoading.current = false; } }, []); useEffect(() => { if (!virtualMode) { virtualLoadGeneration.current += 1; virtualTracksRef.current = []; setVirtualTracks([]); return; } void loadVirtualPage(true); }, [loadVirtualPage, virtualActivePosition, virtualMode, virtualState?.sessionId]); const upcomingTracks = virtualMode ? virtualTracks : rollingUpcomingTracks; const upcomingTotal = virtualState ? Math.max(0, virtualState.totalCount - virtualState.activePosition - 1) : activeIndex >= 0 ? Math.max(0, tracks.length - activeIndex - 1) : tracks.length; const firstVirtualPosition = virtualTracks[0]?.astraQueuePosition; const baseOffset = virtualState ? typeof firstVirtualPosition === 'number' ? firstVirtualPosition : virtualState.activePosition + 1 : 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]); // Built synchronously so a warm mirror paints on the list's first frame; the // update effect below takes over from there. const [entries, setEntries] = useState(() => hasSnapshot ? reconcileQueueEntries(upcomingTracks, [], { current: 0 }, baseOffset) : [] ); const entrySerial = useRef(entries.length); const entriesRef = useRef(entries); const [editMode, setEditMode] = useState(false); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const dStart = useSharedValue(-1); const dTarget = useSharedValue(-1); const dTy = useSharedValue(0); const dActive = useSharedValue(false); const dKey = useSharedValue(''); const dSettling = useSharedValue(false); const dIndexByKey = useSharedValue({}); // 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 // 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. const dragInFlightRef = useRef(false); const clearDragState = useCallback(() => { dragInFlightRef.current = false; runOnUI( ( active: SharedValue, ty: SharedValue, start: SharedValue, target: SharedValue, key: SharedValue, settling: SharedValue, indexMap: SharedValue, count: SharedValue ) => { 'worklet'; active.value = false; settling.value = false; ty.value = 0; start.value = -1; target.value = -1; key.value = ''; indexMap.value = {}; count.value = 0; } )(dActive, dTy, dStart, dTarget, dKey, dSettling, dIndexByKey, dCount); }, [dActive, dCount, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy]); const clearDragAfterReorderCommit = useCallback(() => { requestAnimationFrame(() => { requestAnimationFrame(clearDragState); }); }, [clearDragState]); const updateDragIndexMap = useCallback( (indexMap: QueueIndexByKey, count: number) => { runOnUI( ( sharedIndexMap: SharedValue, sharedCount: SharedValue, nextIndexMap: QueueIndexByKey, nextCount: number ) => { 'worklet'; sharedIndexMap.value = nextIndexMap; sharedCount.value = nextCount; } )(dIndexByKey, dCount, indexMap, count); }, [dCount, dIndexByKey] ); const setVisibleEntries = useCallback((nextEntries: QueueEntry[]) => { entriesRef.current = nextEntries; if (dragInFlightRef.current) { updateDragIndexMap(indexQueueEntriesByKey(nextEntries), nextEntries.length); } setEntries(nextEntries); }, [updateDragIndexMap]); const setOptimisticEntries = useCallback( (nextEntries: QueueEntry[]) => { setVisibleEntries(nextEntries); if (!virtualMode) { useQueueStore.getState().replaceUpcoming(nextEntries.map((entry) => entry.track)); } }, [setVisibleEntries, virtualMode] ); useEffect(() => { setEntries((previous) => { const next = hasSnapshot ? reconcileQueueEntries( upcomingTracks, entriesRef.current.length > 0 ? entriesRef.current : previous, entrySerial, baseOffset, ) : []; // The mount-time reconcile of the synchronous initial state is a no-op; // bail so it doesn't cost a render. const unchanged = next.length === previous.length && next.every((entry, index) => entry === previous[index]); const resolved = unchanged ? previous : next; entriesRef.current = resolved; if (dragInFlightRef.current) { updateDragIndexMap(indexQueueEntriesByKey(resolved), resolved.length); } return resolved; }); }, [baseOffset, hasSnapshot, upcomingTracks, updateDragIndexMap]); const visibleSelectedKeys = useMemo(() => { if (selectedKeys.size === 0) return EMPTY_KEY_SET; const validKeys = new Set(entries.map((entry) => entry.key)); return new Set([...selectedKeys].filter((key) => validKeys.has(key))); }, [entries, selectedKeys]); const retrySetUpcoming = useCallback( (nextTracks: RntpTrack[]) => { if (virtualMode) { void loadVirtualPage(true); return; } useQueueStore.getState().replaceUpcoming(nextTracks); void setUpcoming(nextTracks).catch(() => refresh()); }, [loadVirtualPage, refresh, virtualMode] ); const commitNativeMove = useCallback( (fromAbsolute: number, toAbsolute: number, nextTracks: RntpTrack[]) => { void moveQueueItem(fromAbsolute, toAbsolute, { virtualPosition: virtualMode }) .then(() => { if (virtualMode) void loadVirtualPage(true); }) .catch(() => { retrySetUpcoming(nextTracks); }); }, [loadVirtualPage, retrySetUpcoming, virtualMode] ); const finishDrag = useCallback( (from: number, to: number) => { const snapshot = entriesRef.current; if (from === to || from < 0 || to < 0 || from >= snapshot.length || to >= snapshot.length) { clearDragState(); return; } const positions = snapshot.map((entry) => entry.absoluteIndex); const nextEntries = moveQueueEntry(snapshot, from, to).map((entry, index) => ( entry.absoluteIndex === positions[index] ? entry : { ...entry, absoluteIndex: positions[index] } )); playHaptic('queueDrop'); setVisibleEntries(nextEntries); clearDragAfterReorderCommit(); commitNativeMove( snapshot[from].absoluteIndex, snapshot[to].absoluteIndex, nextEntries.map((entry) => entry.track) ); }, [clearDragAfterReorderCommit, clearDragState, commitNativeMove, setVisibleEntries] ); const onDragArm = useCallback( (entryKey: string) => { dragInFlightRef.current = true; const snapshot = entriesRef.current; const indexMap = indexQueueEntriesByKey(snapshot); const currentIndex = indexMap[entryKey] ?? -1; runOnUI( ( active: SharedValue, key: SharedValue, start: SharedValue, target: SharedValue, sharedIndexMap: SharedValue, sharedCount: SharedValue, armedKey: string, armedIndex: number, nextIndexMap: QueueIndexByKey, armedCount: number ) => { 'worklet'; if (!active.value || key.value !== armedKey) return; sharedIndexMap.value = nextIndexMap; sharedCount.value = armedCount; start.value = armedIndex; target.value = armedIndex; } )( dActive, dKey, dStart, dTarget, dIndexByKey, dCount, entryKey, currentIndex, indexMap, snapshot.length ); playHaptic('queueLift'); }, [dActive, dCount, dIndexByKey, dKey, dStart, dTarget] ); const onDragAbort = useCallback(() => { 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( (entryKey: string): GestureType => Gesture.Pan() // Vertical-only so a horizontal swipe starting on the handle still // falls through to the row's SwipeableRow pan. .activeOffsetY([-2, 2]) .failOffsetX([-14, 14]) .onStart(() => { dStart.value = -1; dTarget.value = -1; dTy.value = 0; dKey.value = entryKey; dSettling.value = false; dActive.value = true; 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), dCount.value ); if (nextTarget !== dTarget.value) { dTarget.value = nextTarget; runOnJS(playHaptic)('frequentStep'); } }) .onEnd(() => { const from = dStart.value; const to = dTarget.value; if (from === to || from < 0 || to < 0) { dTy.value = withTiming(0, motion.quick, (finished) => { if (!finished) return; dActive.value = false; dTy.value = 0; dStart.value = -1; dTarget.value = -1; dSettling.value = false; dKey.value = ''; dIndexByKey.value = {}; runOnJS(onDragAbort)(); }); return; } dTy.value = withTiming((to - from) * QUEUE_ROW_HEIGHT, motion.quick, (finished) => { if (!finished) return; 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)(); }), [ dActive, dCount, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy, finishDrag, onDragAbort, onDragArm, ] ); const runAndRefresh = useCallback( (task: Promise) => { void task.then( () => { if (virtualMode) void loadVirtualPage(true); }, () => { if (virtualMode) void loadVirtualPage(true); else void refresh(); }, ); }, [loadVirtualPage, refresh, virtualMode] ); const jump = useCallback( (key: string) => { const localIndex = entriesRef.current.findIndex((entry) => entry.key === key); if (localIndex < 0) return; const entry = entriesRef.current[localIndex]; runAndRefresh(jumpToQueueIndex(entry.absoluteIndex, { virtualPosition: virtualMode })); }, [runAndRefresh, virtualMode] ); const playNext = useCallback( (key: string) => { const localIndex = entriesRef.current.findIndex((entry) => entry.key === key); if (localIndex < 0) return; const nextEntries = moveQueueEntry(entriesRef.current, localIndex, 0); setOptimisticEntries(nextEntries); runAndRefresh(requeueToTop(entriesRef.current[localIndex].absoluteIndex, { virtualPosition: virtualMode, })); }, [runAndRefresh, setOptimisticEntries, virtualMode] ); const remove = useCallback( (key: string) => { const localIndex = entriesRef.current.findIndex((entry) => entry.key === key); if (localIndex < 0) return; const action = removeQueueEntryAt(entriesRef.current, localIndex, baseOffsetRef.current); if (!action) return; setOptimisticEntries(action.nextEntries); runAndRefresh(removeFromQueue( entriesRef.current[localIndex].absoluteIndex, { updateMirror: false, virtualPosition: virtualMode }, )); }, [runAndRefresh, setOptimisticEntries, virtualMode] ); const toggleSelect = useCallback((key: string) => { setSelectedKeys((previous) => { const next = new Set(previous); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const exitEdit = useCallback(() => { setEditMode(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); const absoluteIndices = entriesRef.current .filter((entry) => visibleSelectedKeys.has(entry.key)) .map((entry) => entry.absoluteIndex); if (absoluteIndices.length === 0) { clearSelection(); return; } setOptimisticEntries(action.entriesWithSelectedFirst); runAndRefresh(requeueManyToTop(absoluteIndices, { virtualPosition: virtualMode })); clearSelection(); }, [ baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, virtualMode, visibleSelectedKeys, ]); const groupRemove = useCallback(() => { const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset); const absoluteIndices = entriesRef.current .filter((entry) => visibleSelectedKeys.has(entry.key)) .map((entry) => entry.absoluteIndex); if (absoluteIndices.length === 0) { clearSelection(); return; } setOptimisticEntries(action.entriesWithoutSelected); runAndRefresh(removeManyFromQueue(absoluteIndices, { updateMirror: false, virtualPosition: virtualMode, })); clearSelection(); }, [ baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, virtualMode, visibleSelectedKeys, ]); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( ), [] ); const queueReady = hasSnapshot; const isLoadingQueue = !queueReady; const listExtraData = useMemo( () => ({ editMode, selectedKeys: visibleSelectedKeys, queueReady }), [editMode, queueReady, visibleSelectedKeys] ); const renderItem = useCallback( ({ item }: ListRenderItemInfo) => ( ), [ dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy, editMode, jump, makeDragGesture, playNext, queueReady, remove, toggleSelect, visibleSelectedKeys, ] ); const renderEmpty = useMemo( () => ( {isLoadingQueue ? ( <> Preparing queue... ) : ( <> Nothing queued Add a song or album to keep the music going. )} ), [isLoadingQueue, colors, styles] ); const selectedCount = visibleSelectedKeys.size; const canEdit = queueReady && entries.length > 0; const body = ( <> Queue {queueCountLabel(upcomingTotal)} {canEdit ? ( (editMode ? exitEdit() : setEditMode(true))} accessibilityRole="button" accessibilityLabel={editMode ? 'Cancel queue editing' : 'Edit queue'} > {editMode ? 'Cancel' : 'Edit'} ) : null} {currentTrack ? ( Playing now {trackTitle(currentTrack)} {trackArtist(currentTrack)} ) : null} Up next {listReady ? ( item.key} drawDistance={QUEUE_ROW_HEIGHT * 12} maintainVisibleContentPosition={{ disabled: true }} renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent} renderItem={renderItem} extraData={listExtraData} onLoad={onListLoad} onEndReached={virtualMode ? () => void loadVirtualPage(false) : undefined} onEndReachedThreshold={0.6} contentContainerStyle={embedded ? styles.embeddedListContent : editMode && selectedCount > 0 ? listContentEditStyle : listContentStyle} showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} /> ) : null} {!listPainted && entries.length > 0 ? ( {entries.slice(0, previewCount).map((entry) => ( ))} ) : null} {editMode && selectedCount > 0 ? ( Play next ({selectedCount}) Remove ({selectedCount}) ) : null} ); if (embedded) { return {body}; } return ( {body} ); }); const Artwork = memo(function Artwork({ uri, title }: { uri?: string; title?: string }) { const styles = useStyles(); return ( {uri ? ( ) : ( )} ); }); // Static stand-in for QueueRow while the real list is gated/painting: identical // geometry and visuals (idle rows carry no transforms), no worklets, gestures, // or press feedback — it exists only to be looked at for a few frames. const QueuePreviewRow = memo(function QueuePreviewRow({ entry }: { entry: QueueEntry }) { const styles = useStyles(); const colors = useColors(); const title = trackTitle(entry.track); return ( {title} {trackArtist(entry.track)} ); }); interface QueueRowProps { entry: QueueEntry; actionsEnabled: boolean; editMode: boolean; selected: boolean; makeDragGesture: (entryKey: string) => GestureType; dStart: SharedValue; dTarget: SharedValue; dTy: SharedValue; dActive: SharedValue; dKey: SharedValue; dSettling: SharedValue; dIndexByKey: SharedValue; onJumpKey: (key: string) => void; onPlayNextKey: (key: string) => void; onRemoveKey: (key: string) => void; onToggleSelectKey: (key: string) => void; } const QueueRow = memo(function QueueRow({ entry, actionsEnabled, editMode, selected, makeDragGesture, dStart, dTarget, dTy, dActive, dKey, dSettling, dIndexByKey, onJumpKey, onPlayNextKey, onRemoveKey, onToggleSelectKey, }: QueueRowProps) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); const entryKey = entry.key; const title = trackTitle(entry.track); const artist = trackArtist(entry.track); const gesture = useMemo(() => makeDragGesture(entryKey), [entryKey, makeDragGesture]); const onJump = useCallback(() => onJumpKey(entryKey), [entryKey, onJumpKey]); const onPlayNext = useCallback(() => onPlayNextKey(entryKey), [entryKey, onPlayNextKey]); const onRemove = useCallback(() => onRemoveKey(entryKey), [entryKey, onRemoveKey]); const onToggleSelect = useCallback( () => onToggleSelectKey(entryKey), [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(() => { if (!dActive.value || dStart.value < 0) { return { transform: [{ translateY: 0 }, { scale: 1 }], zIndex: 0, elevation: 0, shadowOpacity: 0, }; } const currentIndex = dIndexByKey.value[entryKey] ?? -1; if (dKey.value === entryKey) { const baseIndexDelta = currentIndex < 0 ? 0 : (currentIndex - dStart.value) * QUEUE_ROW_HEIGHT; return { transform: [ { translateY: dTy.value - baseIndexDelta }, { scale: withTiming(dSettling.value ? 1 : 1.025, motion.quick) }, ], zIndex: 30, elevation: 8, shadowOpacity: withTiming(dSettling.value ? 0 : 0.22, motion.quick), }; } if (dSettling.value) { return { transform: [{ translateY: withTiming(0, motion.quick) }, { scale: 1 }], zIndex: 0, elevation: 0, shadowOpacity: 0, }; } const start = dStart.value; const target = dTarget.value; let shift = 0; if (currentIndex >= 0) { if (start < target && currentIndex > start && currentIndex <= target) { shift = -QUEUE_ROW_HEIGHT; } else if (start > target && currentIndex >= target && currentIndex < start) { shift = QUEUE_ROW_HEIGHT; } } return { transform: [{ translateY: withTiming(shift, motion.quick) }, { scale: 1 }], zIndex: 0, elevation: 0, shadowOpacity: 0, }; }); // Locals so the worklet captures plain strings: a theme switch re-renders, // the captured values change, and Reanimated rebuilds the worklet. const dragSurface = colors.bgTertiary; const selectedSurface = colors.glassHighlight; const restSurface = colors.bgSecondary; const rowSurfaceStyle = useAnimatedStyle(() => ({ backgroundColor: dActive.value && dKey.value === entryKey ? dragSurface : selected ? selectedSurface : restSurface, })); const rowContent = ( {editMode ? ( ) : null} {title} {artist} {actionsEnabled ? ( ) : null} ); if (!actionsEnabled) { return ( {rowContent} ); } if (editMode) { return ( {rowContent} ); } return ( {rowContent} ); }); const useStyles = createThemedStyles((colors) => ({ sheetBg: { backgroundColor: colors.bgSecondary, borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg, }, handle: { backgroundColor: colors.glassBorder, width: 38, }, embeddedRoot: { flex: 1, overflow: 'hidden', }, embeddedListContent: { flexGrow: 1, paddingBottom: spacing.lg, }, headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.lg, paddingBottom: spacing.md, }, headerText: { flex: 1, minWidth: 0, }, headerTitle: { fontSize: 20, }, headerCount: { color: colors.textTertiary, marginTop: 2, }, editBtn: { color: colors.accent, }, sectionLabel: { color: colors.textTertiary, fontSize: 11, letterSpacing: 0, paddingHorizontal: spacing.lg, }, upcomingLabel: { marginTop: spacing.md, marginBottom: spacing.xs, }, nowPlaying: { paddingBottom: spacing.xs, }, nowPlayingCard: { height: QUEUE_ROW_HEIGHT, flexDirection: 'row', alignItems: 'center', gap: spacing.md, marginHorizontal: spacing.lg, marginTop: spacing.xs, paddingHorizontal: spacing.md, borderRadius: radius.sm, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, backgroundColor: colors.glassBg, }, listArea: { flex: 1, overflow: 'hidden', }, listPreview: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, listContent: { flexGrow: 1, }, // Matches the idle rowSurfaceStyle background the real rows get from their // worklet; the preview has no worklets so it carries the color statically. rowRest: { backgroundColor: colors.bgSecondary, }, empty: { alignItems: 'center', justifyContent: 'center', paddingHorizontal: spacing.xl, paddingVertical: spacing.xxl, }, emptyTitle: { marginTop: spacing.md, textAlign: 'center', }, emptyCopy: { marginTop: spacing.xs, textAlign: 'center', color: colors.textTertiary, }, rowOuter: { height: QUEUE_ROW_HEIGHT, shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowRadius: 18, }, rowPressable: { height: QUEUE_ROW_HEIGHT, }, row: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, height: QUEUE_ROW_HEIGHT, paddingHorizontal: spacing.lg, borderBottomColor: colors.glassBorder, borderBottomWidth: StyleSheet.hairlineWidth, }, checkbox: { width: 24, alignItems: 'center', }, art: { width: ART, height: ART, flexShrink: 0, borderRadius: radius.sm, backgroundColor: colors.bgTertiary, borderColor: colors.glassBorder, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, artImage: { width: '100%', height: '100%', }, meta: { flex: 1, minWidth: 0, gap: 2, }, title: { fontSize: 15, }, titleActive: { fontSize: 15, color: colors.accentTextStrong, }, artistActive: { color: colors.accentText, }, dragHandle: { width: 34, height: QUEUE_ROW_HEIGHT, alignItems: 'center', justifyContent: 'center', }, actionBar: { flexDirection: 'row', borderTopColor: colors.glassBorder, borderTopWidth: StyleSheet.hairlineWidth, backgroundColor: colors.bgTertiary, }, actionBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.sm, paddingVertical: spacing.md, }, actionText: { color: colors.accent, }, actionTextDestructive: { color: colors.warning, }, })); export default QueueTray;