queue panel

This commit is contained in:
Boof2015
2026-06-18 12:58:09 -04:00
parent 97522ea5cf
commit 5853edd933
13 changed files with 1807 additions and 38 deletions
+149
View File
@@ -0,0 +1,149 @@
import { useState, type ReactNode } from 'react';
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { colors } from '@/theme';
import { motion } from '@/theme/motion';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
type IconName = keyof typeof Ionicons.glyphMap;
export interface SwipeAction {
icon: IconName;
/** Background of the revealed action lane. */
color: string;
iconColor?: string;
onCommit: () => void;
}
interface SwipeableRowProps {
/** Revealed on the LEFT as the row is dragged right (a rightward swipe). */
swipeRight?: SwipeAction;
/** Revealed on the RIGHT as the row is dragged left (a leftward swipe). */
swipeLeft?: SwipeAction;
/**
* Optional vertical gesture (e.g. a hold-to-drag reorder) raced against the
* horizontal swipe in the SAME detector — composing here avoids the nested
* GestureDetectors that broke continuous hold-and-drag.
*/
dragGesture?: GestureType;
enabled?: boolean;
children: ReactNode;
}
/**
* Horizontally swipeable row. Translation is clamped to ±width/2; a haptic tick
* fires when crossing the ±width/4 "arm" point in either direction (arming to
* commit, or backing off). Releasing past the arm point runs the matching
* action; otherwise the row springs back. Vertical drags fall through so the row
* still scrolls / lets a parent sheet pan.
*/
export function SwipeableRow({
swipeRight,
swipeLeft,
dragGesture,
enabled = true,
children,
}: SwipeableRowProps) {
const tx = useSharedValue(0);
const armed = useSharedValue(false);
const [rowWidth, setRowWidth] = useState(0);
const max = rowWidth / 2;
const arm = rowWidth / 4;
const hasRight = !!swipeRight;
const hasLeft = !!swipeLeft;
const onLayout = (e: LayoutChangeEvent) => setRowWidth(e.nativeEvent.layout.width);
const onCommit = (direction: 'right' | 'left') => {
if (direction === 'right') swipeRight?.onCommit();
else swipeLeft?.onCommit();
commitHaptic();
};
const pan = Gesture.Pan()
.enabled(enabled && rowWidth > 0 && (hasRight || hasLeft))
.activeOffsetX([-12, 12])
.failOffsetY([-12, 12])
.onUpdate((e) => {
let t = e.translationX;
if (t > 0 && !hasRight) t = 0;
if (t < 0 && !hasLeft) t = 0;
t = Math.max(-max, Math.min(max, t));
tx.value = t;
const nowArmed = Math.abs(t) >= arm;
if (nowArmed !== armed.value) {
armed.value = nowArmed;
runOnJS(tickHaptic)();
}
})
.onEnd(() => {
const t = tx.value;
if (t >= arm && hasRight) runOnJS(onCommit)('right');
else if (t <= -arm && hasLeft) runOnJS(onCommit)('left');
armed.value = false;
tx.value = withTiming(0, motion.quick);
});
// Race so a horizontal swipe and a (long-press) vertical drag never fight:
// whichever activates first wins and cancels the other.
const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan;
const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
const leftLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value > 1 ? 1 : 0 }));
const rightLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value < -1 ? 1 : 0 }));
return (
<View style={styles.wrap} onLayout={onLayout}>
{swipeRight ? (
<Animated.View
pointerEvents="none"
style={[styles.lane, styles.laneLeft, { backgroundColor: swipeRight.color }, leftLaneStyle]}
>
<Ionicons name={swipeRight.icon} size={22} color={swipeRight.iconColor ?? colors.bgPrimary} />
</Animated.View>
) : null}
{swipeLeft ? (
<Animated.View
pointerEvents="none"
style={[styles.lane, styles.laneRight, { backgroundColor: swipeLeft.color }, rightLaneStyle]}
>
<Ionicons name={swipeLeft.icon} size={22} color={swipeLeft.iconColor ?? colors.bgPrimary} />
</Animated.View>
) : null}
<GestureDetector gesture={gesture}>
<Animated.View style={contentStyle}>{children}</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
wrap: {
position: 'relative',
overflow: 'hidden',
},
lane: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
laneLeft: {
justifyContent: 'flex-start',
},
laneRight: {
justifyContent: 'flex-end',
},
});
+29 -1
View File
@@ -4,9 +4,12 @@ import { Image } from 'expo-image';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { SwipeableRow } from '@/components/SwipeableRow';
import { colors, radius, spacing } from '@/theme';
import { formatDuration } from '@/lib/format';
import { artworkThumbUri } from '@/library/artwork';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { enqueueEnd, enqueueTop } from '@/audio/playbackController';
import type { DbTrack } from '@/types/library';
const ART_SIZE = 44;
@@ -18,6 +21,7 @@ export function TrackRow({
onLongPress,
showArtist = true,
active = false,
swipeToQueue = true,
}: {
track: DbTrack;
onPress: () => void;
@@ -26,6 +30,8 @@ export function TrackRow({
/** Hide on album detail where every row shares the artist. */
showArtist?: boolean;
active?: boolean;
/** Swipe right → play next, swipe left → add to queue. Off in queue-like lists. */
swipeToQueue?: boolean;
}) {
const artworkHash = track.artwork_hash;
const [failedArtworkHash, setFailedArtworkHash] = useState<string | null>(null);
@@ -33,7 +39,7 @@ export function TrackRow({
const thumbUri =
artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null;
return (
const row = (
<Pressable
style={styles.row}
onPress={onPress}
@@ -92,6 +98,26 @@ export function TrackRow({
</Text>
</Pressable>
);
if (!swipeToQueue) return row;
return (
<SwipeableRow
swipeRight={{
icon: 'play',
color: colors.accent,
onCommit: () => void enqueueTop(dbTrackToTrack(track)),
}}
swipeLeft={{
icon: 'list',
color: colors.bgTertiary,
iconColor: colors.accentText,
onCommit: () => void enqueueEnd(dbTrackToTrack(track)),
}}
>
{row}
</SwipeableRow>
);
}
const styles = StyleSheet.create({
@@ -101,6 +127,8 @@ const styles = StyleSheet.create({
minHeight: ROW_MIN_HEIGHT,
paddingVertical: spacing.sm + 2,
gap: spacing.md,
// Opaque so the swipe action lane only shows where the row has slid away.
backgroundColor: colors.bgPrimary,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { Event, useTrackPlayerEvents, type Track as RntpTrack } from 'react-native-track-player';
import { useQueueStore } from '@/stores/queueStore';
export interface QueueSnapshot {
tracks: RntpTrack[];
activeIndex: number;
hasSnapshot: boolean;
refresh: () => Promise<void>;
}
/**
* Live view of the JS queue mirror. Opening the tray only falls back to RNTP's
* full native queue read when no playback action has populated the mirror yet.
*/
export function useQueue(active: boolean): QueueSnapshot {
const tracks = useQueueStore((s) => s.tracks);
const activeIndex = useQueueStore((s) => s.activeIndex);
const hasSnapshot = useQueueStore((s) => s.hasSnapshot);
const refresh = useQueueStore((s) => s.refreshFromNative);
const refreshActiveIndex = useQueueStore((s) => s.refreshActiveIndex);
const setActiveIndex = useQueueStore((s) => s.setActiveIndex);
useEffect(() => {
if (!active) return;
if (hasSnapshot) void refreshActiveIndex();
else void refresh();
}, [active, hasSnapshot, refresh, refreshActiveIndex]);
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], (event) => {
if (!active) return;
if (hasSnapshot) setActiveIndex(event.index ?? -1);
else void refresh();
});
return { tracks, activeIndex, hasSnapshot, refresh };
}