mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
queue panel
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user