Files
astra-mobile/src/components/queue/QueueTray.tsx
T
2026-07-01 18:33:22 -04:00

1046 lines
30 KiB
TypeScript

import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Pressable, StyleSheet, View } 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 { colors, radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { dragArmHaptic, tickHaptic } from '@/lib/haptics';
import { useQueueStore } from '@/stores/queueStore';
import {
jumpToQueueIndex,
moveQueueItem,
removeFromQueue,
removeManyFromQueue,
requeueManyToTop,
requeueToTop,
setUpcoming,
} from '@/audio/playbackController';
import { useQueue } from './useQueue';
const QUEUE_ROW_HEIGHT = 64;
const ART = 42;
const EMPTY_KEY_SET = new Set<string>();
interface QueueEntry {
key: string;
identity: string;
track: RntpTrack;
}
type QueueIndexByKey = Record<string, 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 {
return typeof track.artwork === 'string' ? 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 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 {
'worklet';
return Math.max(0, Math.min(len - 1, value));
}
function reconcileQueueEntries(
tracks: readonly RntpTrack[],
previous: readonly QueueEntry[],
nextSerial: { current: number }
): QueueEntry[] {
const available = new Map<string, QueueEntry[]>();
previous.forEach((entry) => {
const bucket = available.get(entry.identity);
if (bucket) bucket.push(entry);
else available.set(entry.identity, [entry]);
});
return tracks.map((track) => {
const identity = rntpKey(track);
const reused = available.get(identity)?.shift();
if (reused) return { ...reused, track, identity };
const key = `${identity}:${nextSerial.current}`;
nextSerial.current += 1;
return { key, identity, track };
});
}
interface QueueTrayProps {
onClose: () => void;
}
export function QueueTray({ onClose }: QueueTrayProps) {
const insets = useSafeAreaInsets();
const snapPoints = useMemo(() => ['58%', '100%'], []);
const renderFlashListScrollComponent = useBottomSheetScrollableCreator();
const { tracks, activeIndex, hasSnapshot, refresh } = useQueue(true);
const currentTrack = activeIndex >= 0 ? tracks[activeIndex] : undefined;
const upcomingTracks = useMemo(
() => (activeIndex >= 0 ? tracks.slice(activeIndex + 1) : tracks),
[tracks, activeIndex]
);
const upcomingTotal =
activeIndex >= 0 ? Math.max(0, tracks.length - activeIndex - 1) : tracks.length;
const baseOffset = activeIndex >= 0 ? activeIndex + 1 : 0;
const entrySerial = useRef(0);
const entriesRef = useRef<QueueEntry[]>([]);
const [entries, setEntries] = useState<QueueEntry[]>([]);
const [editMode, setEditMode] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(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<QueueIndexByKey>({});
// 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<boolean>,
ty: SharedValue<number>,
start: SharedValue<number>,
target: SharedValue<number>,
key: SharedValue<string>,
settling: SharedValue<boolean>,
indexMap: SharedValue<QueueIndexByKey>
) => {
'worklet';
active.value = false;
settling.value = false;
ty.value = 0;
start.value = -1;
target.value = -1;
key.value = '';
indexMap.value = {};
}
)(dActive, dTy, dStart, dTarget, dKey, dSettling, dIndexByKey);
}, [dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy]);
const clearDragAfterReorderCommit = useCallback(() => {
requestAnimationFrame(() => {
requestAnimationFrame(clearDragState);
});
}, [clearDragState]);
const updateDragIndexMap = useCallback(
(indexMap: QueueIndexByKey) => {
runOnUI((sharedIndexMap: SharedValue<QueueIndexByKey>, nextIndexMap: QueueIndexByKey) => {
'worklet';
sharedIndexMap.value = nextIndexMap;
})(dIndexByKey, indexMap);
},
[dIndexByKey]
);
const setVisibleEntries = useCallback((nextEntries: QueueEntry[]) => {
entriesRef.current = nextEntries;
if (dragInFlightRef.current) updateDragIndexMap(indexByEntryKey(nextEntries));
setEntries(nextEntries);
}, [updateDragIndexMap]);
const setOptimisticEntries = useCallback(
(nextEntries: QueueEntry[]) => {
setVisibleEntries(nextEntries);
useQueueStore.getState().replaceUpcoming(nextEntries.map((entry) => entry.track));
},
[setVisibleEntries]
);
useEffect(() => {
let cancelled = false;
let frame: number | null = null;
frame = requestAnimationFrame(() => {
if (cancelled) return;
setEditMode(false);
setSelectedKeys(new Set());
setEntries((previous) => {
const next = hasSnapshot
? reconcileQueueEntries(
upcomingTracks,
entriesRef.current.length > 0 ? entriesRef.current : previous,
entrySerial
)
: [];
entriesRef.current = next;
if (dragInFlightRef.current) updateDragIndexMap(indexByEntryKey(next));
return next;
});
});
return () => {
cancelled = true;
if (frame != null) cancelAnimationFrame(frame);
};
}, [hasSnapshot, setVisibleEntries, 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[]) => {
useQueueStore.getState().replaceUpcoming(nextTracks);
void setUpcoming(nextTracks).catch(() => refresh());
},
[refresh]
);
const commitNativeMove = useCallback(
(fromAbsolute: number, toAbsolute: number, nextTracks: RntpTrack[]) => {
void moveQueueItem(fromAbsolute, toAbsolute).catch(() => {
retrySetUpcoming(nextTracks);
});
},
[retrySetUpcoming]
);
const finishDrag = useCallback(
(from: number, to: number) => {
const snapshot = entriesRef.current;
if (from === to || from < 0 || to < 0 || from >= snapshot.length || to >= snapshot.length) {
return;
}
const nextEntries = arrayMove(snapshot, from, to);
setVisibleEntries(nextEntries);
clearDragAfterReorderCommit();
commitNativeMove(
baseOffset + from,
baseOffset + to,
nextEntries.map((entry) => entry.track)
);
},
[baseOffset, clearDragAfterReorderCommit, commitNativeMove, setVisibleEntries]
);
const onDragArm = useCallback(() => {
dragInFlightRef.current = true;
dragArmHaptic();
}, []);
const onDragAbort = useCallback(() => {
dragInFlightRef.current = 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;
dTy.value = 0;
dKey.value = entryKey;
dSettling.value = false;
dActive.value = true;
runOnJS(onDragArm)();
})
.onUpdate((event) => {
dTy.value = event.translationY;
const nextTarget = clampLocal(
Math.round(dStart.value + event.translationY / QUEUE_ROW_HEIGHT),
entryCount
);
if (nextTarget !== dTarget.value) {
dTarget.value = nextTarget;
runOnJS(tickHaptic)();
}
})
.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);
});
});
return longPress ? gesture.activateAfterLongPress(250) : gesture;
},
[dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy, finishDrag, onDragAbort, onDragArm]
);
const runAndRefresh = useCallback(
(task: Promise<void>) => {
void task.catch(() => refresh());
},
[refresh]
);
const jump = useCallback(
(localIndex: number) => {
runAndRefresh(jumpToQueueIndex(baseOffset + localIndex));
},
[baseOffset, runAndRefresh]
);
const playNext = useCallback(
(localIndex: number) => {
const nextEntries = arrayMove(entriesRef.current, localIndex, 0);
setOptimisticEntries(nextEntries);
runAndRefresh(requeueToTop(baseOffset + localIndex));
},
[baseOffset, runAndRefresh, setOptimisticEntries]
);
const remove = useCallback(
(localIndex: number) => {
const nextEntries = entriesRef.current.filter((_, index) => index !== localIndex);
setOptimisticEntries(nextEntries);
runAndRefresh(removeFromQueue(baseOffset + localIndex));
},
[baseOffset, runAndRefresh, setOptimisticEntries]
);
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 selectedAbsoluteIndices = useCallback((): number[] => {
const indices: number[] = [];
entriesRef.current.forEach((entry, index) => {
if (visibleSelectedKeys.has(entry.key)) indices.push(baseOffset + index);
});
return indices;
}, [baseOffset, visibleSelectedKeys]);
const groupPlayNext = useCallback(() => {
const selected = new Set(visibleSelectedKeys);
const moved = entriesRef.current.filter((entry) => selected.has(entry.key));
const rest = entriesRef.current.filter((entry) => !selected.has(entry.key));
setOptimisticEntries([...moved, ...rest]);
runAndRefresh(requeueManyToTop(selectedAbsoluteIndices()));
exitEdit();
}, [exitEdit, runAndRefresh, selectedAbsoluteIndices, setOptimisticEntries, visibleSelectedKeys]);
const groupRemove = useCallback(() => {
const selected = new Set(visibleSelectedKeys);
const nextEntries = entriesRef.current.filter((entry) => !selected.has(entry.key));
setOptimisticEntries(nextEntries);
runAndRefresh(removeManyFromQueue(selectedAbsoluteIndices()));
exitEdit();
}, [exitEdit, runAndRefresh, selectedAbsoluteIndices, setOptimisticEntries, visibleSelectedKeys]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
pressBehavior="close"
opacity={0.58}
/>
),
[]
);
const queueReady = hasSnapshot;
const isLoadingQueue = !queueReady;
const listExtraData = useMemo(
() => ({ editMode, selectedKeys: visibleSelectedKeys, queueReady }),
[editMode, queueReady, visibleSelectedKeys]
);
const renderItem = useCallback(
({ item, index }: ListRenderItemInfo<QueueEntry>) => (
<QueueRow
entry={item}
entryCount={entries.length}
localIndex={index}
actionsEnabled={queueReady}
editMode={editMode}
selected={visibleSelectedKeys.has(item.key)}
makeDragGesture={makeDragGesture}
dStart={dStart}
dTarget={dTarget}
dTy={dTy}
dActive={dActive}
dKey={dKey}
dSettling={dSettling}
dIndexByKey={dIndexByKey}
onJumpIndex={jump}
onPlayNextIndex={playNext}
onRemoveIndex={remove}
onToggleSelectKey={toggleSelect}
/>
),
[
dActive,
dIndexByKey,
dKey,
dSettling,
dStart,
dTarget,
dTy,
editMode,
entries.length,
jump,
makeDragGesture,
playNext,
queueReady,
remove,
toggleSelect,
visibleSelectedKeys,
]
);
const renderEmpty = useMemo(
() => (
<View style={styles.empty}>
{isLoadingQueue ? (
<>
<Ionicons name="list-outline" size={28} color={colors.textTertiary} />
<Text variant="body" style={styles.emptyTitle}>
Preparing queue...
</Text>
</>
) : (
<>
<Ionicons name="musical-notes-outline" size={28} color={colors.textTertiary} />
<Text variant="body" style={styles.emptyTitle}>
Nothing queued
</Text>
<Text variant="label" style={styles.emptyCopy}>
Add a song or album to keep the music going.
</Text>
</>
)}
</View>
),
[isLoadingQueue]
);
const selectedCount = visibleSelectedKeys.size;
const canEdit = queueReady && entries.length > 0;
return (
<BottomSheet
index={0}
snapPoints={snapPoints}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={!editMode}
enableHandlePanningGesture
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
<View style={styles.headerRow}>
<View style={styles.headerText}>
<Text variant="heading" style={styles.headerTitle}>
Queue
</Text>
<Text variant="label" numberOfLines={1} style={styles.headerCount}>
{queueCountLabel(upcomingTotal)}
</Text>
</View>
{canEdit ? (
<Pressable
hitSlop={10}
onPress={() => (editMode ? exitEdit() : setEditMode(true))}
accessibilityRole="button"
accessibilityLabel={editMode ? 'Cancel queue editing' : 'Edit queue'}
>
<Text variant="label" style={styles.editBtn}>
{editMode ? 'Cancel' : 'Edit'}
</Text>
</Pressable>
) : null}
</View>
{currentTrack ? (
<View style={styles.nowPlaying}>
<Text variant="caption" style={styles.sectionLabel}>
Playing now
</Text>
<View style={styles.nowPlayingCard}>
<Artwork uri={artworkUri(currentTrack)} title={trackTitle(currentTrack)} />
<View style={styles.meta}>
<Text variant="body" numberOfLines={1} style={styles.titleActive}>
{trackTitle(currentTrack)}
</Text>
<Text variant="label" numberOfLines={1} style={styles.artistActive}>
{trackArtist(currentTrack)}
</Text>
</View>
<Ionicons name="volume-high" size={18} color={colors.accent} />
</View>
</View>
) : null}
<Text variant="caption" style={[styles.sectionLabel, styles.upcomingLabel]}>
Up next
</Text>
<FlashList
data={entries}
scrollEnabled={!editMode}
keyExtractor={(item) => item.key}
drawDistance={QUEUE_ROW_HEIGHT * 12}
maintainVisibleContentPosition={{ disabled: true }}
renderScrollComponent={renderFlashListScrollComponent}
renderItem={renderItem}
extraData={listExtraData}
contentContainerStyle={[
styles.listContent,
editMode && selectedCount > 0 ? styles.listContentWithActionBar : null,
]}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
/>
{editMode && selectedCount > 0 ? (
<View style={[styles.actionBar, { paddingBottom: insets.bottom + spacing.sm }]}>
<Pressable
style={({ pressed }) => [styles.actionBtn, pressed && styles.actionBtnPressed]}
onPress={groupPlayNext}
accessibilityRole="button"
accessibilityLabel={`Play ${selectedCount} selected songs next`}
>
<Ionicons name="play-skip-forward" size={18} color={colors.accent} />
<Text variant="label" style={styles.actionText}>
Play next ({selectedCount})
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.actionBtn, pressed && styles.actionBtnPressed]}
onPress={groupRemove}
accessibilityRole="button"
accessibilityLabel={`Remove ${selectedCount} selected songs from queue`}
>
<Ionicons name="trash-outline" size={18} color={colors.warning} />
<Text variant="label" style={[styles.actionText, styles.actionTextDestructive]}>
Remove ({selectedCount})
</Text>
</Pressable>
</View>
) : null}
</BottomSheet>
);
}
const Artwork = memo(function Artwork({ uri, title }: { uri?: string; title?: string }) {
return (
<View style={styles.art}>
{uri ? (
<Image
source={{ uri }}
style={styles.artImage}
contentFit="cover"
cachePolicy="memory-disk"
recyclingKey={uri}
transition={null}
allowDownscaling
accessibilityLabel={title ? `${title} artwork` : undefined}
/>
) : (
<AstraLogo size={16} />
)}
</View>
);
});
interface QueueRowProps {
entry: QueueEntry;
entryCount: number;
localIndex: number;
actionsEnabled: boolean;
editMode: boolean;
selected: boolean;
makeDragGesture: (
localIndex: number,
longPress: boolean,
entryKey: string,
entryCount: number
) => GestureType;
dStart: SharedValue<number>;
dTarget: SharedValue<number>;
dTy: SharedValue<number>;
dActive: SharedValue<boolean>;
dKey: SharedValue<string>;
dSettling: SharedValue<boolean>;
dIndexByKey: SharedValue<QueueIndexByKey>;
onJumpIndex: (localIndex: number) => void;
onPlayNextIndex: (localIndex: number) => void;
onRemoveIndex: (localIndex: number) => void;
onToggleSelectKey: (key: string) => void;
}
const QueueRow = memo(function QueueRow({
entry,
entryCount,
localIndex,
actionsEnabled,
editMode,
selected,
makeDragGesture,
dStart,
dTarget,
dTy,
dActive,
dKey,
dSettling,
dIndexByKey,
onJumpIndex,
onPlayNextIndex,
onRemoveIndex,
onToggleSelectKey,
}: QueueRowProps) {
const entryKey = entry.key;
const title = trackTitle(entry.track);
const artist = trackArtist(entry.track);
const gesture = useMemo(
() => makeDragGesture(localIndex, !editMode, entryKey, entryCount),
[editMode, entryCount, entryKey, localIndex, makeDragGesture]
);
const onJump = useCallback(() => onJumpIndex(localIndex), [localIndex, onJumpIndex]);
const onPlayNext = useCallback(
() => onPlayNextIndex(localIndex),
[localIndex, onPlayNextIndex]
);
const onRemove = useCallback(() => onRemoveIndex(localIndex), [localIndex, onRemoveIndex]);
const onToggleSelect = useCallback(
() => onToggleSelectKey(entryKey),
[entryKey, onToggleSelectKey]
);
const rowMotionStyle = useAnimatedStyle(() => {
if (!dActive.value) {
return {
transform: [
{ translateY: withTiming(0, motion.quick) },
{ scale: withTiming(1, motion.quick) },
],
zIndex: 0,
elevation: 0,
shadowOpacity: withTiming(0, motion.quick),
};
}
if (dKey.value === entryKey) {
const currentIndex = dIndexByKey.value[entryKey] ?? localIndex;
const baseIndexDelta = (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),
};
}
const currentIndex = dIndexByKey.value[entryKey] ?? localIndex;
if (dSettling.value) {
return {
transform: [
{ translateY: withTiming(0, motion.quick) },
{ scale: withTiming(1, motion.quick) },
],
zIndex: 0,
elevation: 0,
shadowOpacity: withTiming(0, motion.quick),
};
}
const start = dStart.value;
const target = dTarget.value;
let shift = 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: withTiming(1, motion.quick) },
],
zIndex: 0,
elevation: 0,
shadowOpacity: withTiming(0, motion.quick),
};
});
const rowSurfaceStyle = useAnimatedStyle(() => ({
backgroundColor:
dActive.value && dKey.value === entryKey
? colors.bgTertiary
: selected
? colors.glassHighlight
: colors.bgSecondary,
}));
const rowContent = (
<Animated.View style={[styles.row, rowSurfaceStyle]}>
{editMode ? (
<Pressable
hitSlop={8}
onPress={onToggleSelect}
style={styles.checkbox}
accessibilityRole="checkbox"
accessibilityState={{ checked: selected }}
accessibilityLabel={`${selected ? 'Deselect' : 'Select'} ${title}`}
>
<Ionicons
name={selected ? 'checkmark-circle' : 'ellipse-outline'}
size={22}
color={selected ? colors.accent : colors.textTertiary}
/>
</Pressable>
) : null}
<Artwork uri={artworkUri(entry.track)} title={title} />
<View style={styles.meta}>
<Text variant="body" numberOfLines={1} style={styles.title}>
{title}
</Text>
<Text variant="label" numberOfLines={1}>
{artist}
</Text>
</View>
{editMode ? (
<GestureDetector gesture={gesture}>
<View
style={styles.dragHandle}
accessible
accessibilityLabel={`Reorder ${title}`}
>
<Ionicons name="reorder-three" size={24} color={colors.textTertiary} />
</View>
</GestureDetector>
) : null}
</Animated.View>
);
if (!actionsEnabled) {
return (
<Animated.View style={[styles.rowOuter, rowMotionStyle]}>
<Pressable
onPress={onJump}
style={({ pressed }) => [styles.rowPressable, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel={`Play ${title}`}
accessibilityHint="Opens this song in the queue"
>
{rowContent}
</Pressable>
</Animated.View>
);
}
if (editMode) {
return (
<Animated.View style={[styles.rowOuter, rowMotionStyle]}>
<Pressable
onPress={onToggleSelect}
style={({ pressed }) => [styles.rowPressable, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityLabel={`${selected ? 'Deselect' : 'Select'} ${title}`}
>
{rowContent}
</Pressable>
</Animated.View>
);
}
return (
<Animated.View style={[styles.rowOuter, rowMotionStyle]}>
<SwipeableRow
dragGesture={gesture}
swipeRight={{ icon: 'play-skip-forward', color: colors.accent, onCommit: onPlayNext }}
swipeLeft={{ icon: 'trash', color: colors.warning, onCommit: onRemove }}
>
<Pressable
onPress={onJump}
style={({ pressed }) => [styles.rowPressable, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel={`Play ${title}`}
accessibilityHint="Opens this song in the queue"
>
{rowContent}
</Pressable>
</SwipeableRow>
</Animated.View>
);
});
const styles = StyleSheet.create({
sheetBg: {
backgroundColor: colors.bgSecondary,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
},
handle: {
backgroundColor: colors.glassBorder,
width: 38,
},
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,
},
listContent: {
paddingBottom: spacing.xxl,
flexGrow: 1,
},
listContentWithActionBar: {
paddingBottom: spacing.xxl * 2,
},
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,
},
rowPressed: {
opacity: 0.72,
},
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,
},
actionBtnPressed: {
opacity: 0.7,
},
actionText: {
color: colors.accent,
},
actionTextDestructive: {
color: colors.warning,
},
});
export default QueueTray;