diff --git a/package-lock.json b/package-lock.json
index d8ed9c7..7ce309d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@expo/ui": "~56.0.13",
"@expo/vector-icons": "^15.0.2",
+ "@gorhom/bottom-sheet": "^5.2.14",
"@op-engineering/op-sqlite": "^16.2.1",
"@shopify/flash-list": "2.0.2",
"@shopify/react-native-skia": "2.6.2",
@@ -25,6 +26,7 @@
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.5",
"expo-glass-effect": "~56.0.4",
+ "expo-haptics": "~56.0.3",
"expo-image": "~56.0.9",
"expo-linking": "~56.0.11",
"expo-router": "~56.2.6",
@@ -1961,6 +1963,45 @@
"excpretty": "build/cli.js"
}
},
+ "node_modules/@gorhom/bottom-sheet": {
+ "version": "5.2.14",
+ "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz",
+ "integrity": "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg==",
+ "license": "MIT",
+ "dependencies": {
+ "@gorhom/portal": "1.0.14",
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-native": "*",
+ "react": "*",
+ "react-native": "*",
+ "react-native-gesture-handler": ">=2.16.1",
+ "react-native-reanimated": ">=3.16.0 || >=4.0.0-"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@gorhom/portal": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz",
+ "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==",
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.1"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -6237,6 +6278,15 @@
"react-native": "*"
}
},
+ "node_modules/expo-haptics": {
+ "version": "56.0.3",
+ "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-56.0.3.tgz",
+ "integrity": "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-image": {
"version": "56.0.9",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-56.0.9.tgz",
diff --git a/package.json b/package.json
index d594c4d..8f92696 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@expo/ui": "~56.0.13",
"@expo/vector-icons": "^15.0.2",
+ "@gorhom/bottom-sheet": "^5.2.14",
"@op-engineering/op-sqlite": "^16.2.1",
"@shopify/flash-list": "2.0.2",
"@shopify/react-native-skia": "2.6.2",
@@ -19,6 +20,7 @@
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.5",
"expo-glass-effect": "~56.0.4",
+ "expo-haptics": "~56.0.3",
"expo-image": "~56.0.9",
"expo-linking": "~56.0.11",
"expo-router": "~56.2.6",
diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx
index 3dbf012..e845825 100644
--- a/src/app/now-playing.tsx
+++ b/src/app/now-playing.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
import { Image } from 'expo-image';
-import { Ionicons } from '@expo/vector-icons';
+import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
@@ -11,25 +11,26 @@ import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
+ withTiming,
} from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
import { Visualizer } from '@/components/Visualizer';
+import { QueueTray } from '@/components/queue/QueueTray';
import { colors, radius, spacing } from '@/theme';
+import { motion } from '@/theme/motion';
import { usePlayerStore } from '@/stores/playerStore';
-import { seekTo, skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
-
-type IconName = keyof typeof Ionicons.glyphMap;
-
-// Secondary controls are placeholders for now — laid out to settle the design.
-const SUB_CONTROLS: { icon: IconName; label: string }[] = [
- { icon: 'shuffle', label: 'Shuffle' },
- { icon: 'heart-outline', label: 'Favorite' },
- { icon: 'list-outline', label: 'Queue' },
- { icon: 'repeat', label: 'Repeat' },
-];
+import { usePlaylistStore } from '@/stores/playlistStore';
+import {
+ cycleRepeat,
+ seekTo,
+ skipToNext,
+ skipToPrevious,
+ togglePlay,
+ toggleShuffle,
+} from '@/audio/playbackController';
const DISMISS_DISTANCE = 140;
const DISMISS_VELOCITY = 1000;
@@ -128,10 +129,15 @@ export default function NowPlayingScreen() {
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [showScopeStage, setShowScopeStage] = useState(false);
+ const [queueOpen, setQueueOpen] = useState(false);
const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
+ const shuffle = usePlayerStore((s) => s.shuffle);
+ const repeat = usePlayerStore((s) => s.repeat);
+ const isFavorite = usePlaylistStore((s) => (track ? s.favoritePaths.has(track.path) : false));
+ const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
@@ -182,7 +188,7 @@ export default function NowPlayingScreen() {
}
);
} else {
- translateY.value = withSpring(0, { damping: 20, stiffness: 220 });
+ translateY.value = withTiming(0, motion.snap);
}
});
@@ -334,20 +340,65 @@ export default function NowPlayingScreen() {
{layout.showSecondaryControls && (
- {SUB_CONTROLS.map((c) => (
-
- void toggleShuffle()}
+ accessibilityLabel="Shuffle"
+ accessibilityState={{ selected: shuffle }}
+ >
+
+
+ void toggleFavorite(track)}
+ accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
+ accessibilityState={{ selected: isFavorite }}
+ >
+
+
+ setQueueOpen(true)}
+ accessibilityLabel="Queue"
+ >
+
+
+ void cycleRepeat()}
+ accessibilityLabel="Repeat"
+ accessibilityState={{ selected: repeat !== 'none' }}
+ >
+ {repeat === 'one' ? (
+
-
- ))}
+ ) : (
+
+ )}
+
)}
@@ -363,6 +414,7 @@ export default function NowPlayingScreen() {
+ {queueOpen && setQueueOpen(false)} />}
);
}
diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts
index 319074a..3894daa 100644
--- a/src/audio/playbackController.ts
+++ b/src/audio/playbackController.ts
@@ -1,5 +1,11 @@
-import TrackPlayer, { isPlaying } from 'react-native-track-player';
+import TrackPlayer, {
+ isPlaying,
+ RepeatMode,
+ type Track as RntpTrack,
+} from 'react-native-track-player';
import type { Track } from '@/types/audio';
+import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
+import { useQueueStore } from '@/stores/queueStore';
import { setupPlayer } from './trackPlayer';
import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
@@ -9,35 +15,113 @@ import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
* would slot in behind the same function signatures.
*/
+// Unshuffled track-id order for the active context, so shuffle can be toggled
+// off and the upcoming tail restored to its original sequence (mirrors desktop's
+// autoQueue + shuffledAutoIndices split, but over RNTP's flat native queue).
+let originalOrder: string[] | null = null;
+
+const NEXT_REPEAT: Record = {
+ none: 'all',
+ all: 'one',
+ one: 'none',
+};
+
+function toRntpRepeat(mode: RepeatModeStr): RepeatMode {
+ switch (mode) {
+ case 'one':
+ return RepeatMode.Track;
+ case 'all':
+ return RepeatMode.Queue;
+ default:
+ return RepeatMode.Off;
+ }
+}
+
+function rntpTrackId(track: RntpTrack): string {
+ return String(track.id ?? track.url);
+}
+
+async function getQueueSnapshot(): Promise<{ queue: RntpTrack[]; activeIndex: number }> {
+ const store = useQueueStore.getState();
+ const activeIndex = (await TrackPlayer.getActiveTrackIndex()) ?? -1;
+
+ if (store.hasSnapshot) {
+ store.setActiveIndex(activeIndex);
+ return { queue: useQueueStore.getState().tracks, activeIndex };
+ }
+
+ const queue = await TrackPlayer.getQueue();
+ store.setSnapshot(queue, activeIndex);
+ return { queue, activeIndex };
+}
+
+async function refreshActiveIndexFromNative(): Promise {
+ await useQueueStore.getState().refreshActiveIndex();
+}
+
+function syncOriginalOrderFromMirrorIfUnshuffled(): void {
+ if (usePlayerStore.getState().shuffle) return;
+ const { tracks, hasSnapshot } = useQueueStore.getState();
+ if (hasSnapshot) originalOrder = tracks.map(rntpTrackId);
+}
+
+/** Fisher–Yates shuffle a copy of the array. */
+function shuffleArray(items: readonly T[]): T[] {
+ const out = [...items];
+ for (let i = out.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [out[i], out[j]] = [out[j], out[i]];
+ }
+ return out;
+}
+
/**
* Set up the player. Setup is deferred to here (a user-initiated play) rather
* than app launch: RNTP starts a foreground MediaSession service on setup, and
* Android only permits starting a foreground service while the app is in the
- * foreground.
+ * foreground. The stored repeat mode is re-applied after a (re)setup so a
+ * deferred init keeps the user's choice.
*/
async function ensurePlayerReady(): Promise {
await setupPlayer();
+ await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat));
}
/** Replace the queue with the given tracks and start playing at startIndex. */
export async function playTracks(tracks: Track[], startIndex = 0): Promise {
if (tracks.length === 0) return;
await ensurePlayerReady();
- await TrackPlayer.setQueue(tracks.map(toRntpTrack));
+ const queueTracks = tracks.map(toRntpTrack);
+ await TrackPlayer.setQueue(queueTracks);
+ originalOrder = tracks.map((t) => t.id);
if (startIndex > 0) {
await TrackPlayer.skip(startIndex);
}
+ let mirroredQueue = queueTracks;
+ // Honor an already-on shuffle by scrambling the upcoming tail of the new context.
+ if (usePlayerStore.getState().shuffle) {
+ const upcoming = tracks.slice(startIndex + 1);
+ if (upcoming.length > 1) {
+ const shuffledUpcoming = shuffleArray(upcoming).map(toRntpTrack);
+ await TrackPlayer.removeUpcomingTracks();
+ await TrackPlayer.add(shuffledUpcoming);
+ mirroredQueue = [...queueTracks.slice(0, startIndex + 1), ...shuffledUpcoming];
+ }
+ }
+ useQueueStore.getState().setSnapshot(mirroredQueue, startIndex);
await TrackPlayer.play();
}
-/** Fisher–Yates shuffle a copy of the tracks and play from the top. */
+/** Shuffle a context and play from the top (the library/album "Shuffle" buttons). */
export async function shuffleTracks(tracks: Track[]): Promise {
- const shuffled = [...tracks];
- for (let i = shuffled.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
- }
- await playTracks(shuffled);
+ if (tracks.length === 0) return;
+ await ensurePlayerReady();
+ originalOrder = tracks.map((t) => t.id);
+ usePlayerStore.getState().setShuffle(true);
+ const queueTracks = shuffleArray(tracks).map(toRntpTrack);
+ await TrackPlayer.setQueue(queueTracks);
+ useQueueStore.getState().setSnapshot(queueTracks, 0);
+ await TrackPlayer.play();
}
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
@@ -45,7 +129,13 @@ export async function playSample(): Promise {
await ensurePlayerReady();
const queue = await TrackPlayer.getQueue();
if (queue.length === 0) {
- await TrackPlayer.add(SAMPLE_TRACKS.map(toRntpTrack));
+ const sampleQueue = SAMPLE_TRACKS.map(toRntpTrack);
+ await TrackPlayer.add(sampleQueue);
+ originalOrder = SAMPLE_TRACKS.map((t) => t.id);
+ useQueueStore.getState().setSnapshot(sampleQueue, 0);
+ } else {
+ const activeIndex = await TrackPlayer.getActiveTrackIndex();
+ useQueueStore.getState().setSnapshot(queue, activeIndex);
}
await TrackPlayer.play();
}
@@ -67,6 +157,7 @@ export async function togglePlay(): Promise {
export async function skipToNext(): Promise {
try {
await TrackPlayer.skipToNext();
+ await refreshActiveIndexFromNative();
} catch {
// no next track — ignore
}
@@ -75,7 +166,167 @@ export async function skipToNext(): Promise {
export async function skipToPrevious(): Promise {
try {
await TrackPlayer.skipToPrevious();
+ await refreshActiveIndexFromNative();
} catch {
// no previous track — ignore
}
}
+
+/** Cycle repeat none → all → one (desktop order) and push it to RNTP. */
+export async function cycleRepeat(): Promise {
+ const next = NEXT_REPEAT[usePlayerStore.getState().repeat];
+ usePlayerStore.getState().setRepeat(next);
+ await ensurePlayerReady();
+ await TrackPlayer.setRepeatMode(toRntpRepeat(next));
+}
+
+/**
+ * Toggle shuffle. The current track keeps playing untouched (no audio gap); only
+ * the upcoming tail is re-ordered: scrambled when turning on, restored to
+ * `originalOrder` when turning off.
+ */
+export async function toggleShuffle(): Promise {
+ const store = usePlayerStore.getState();
+ const next = !store.shuffle;
+ await ensurePlayerReady();
+
+ const snapshot = await getQueueSnapshot();
+ const queue = snapshot.queue;
+ const activeIndex = snapshot.activeIndex >= 0 ? snapshot.activeIndex : 0;
+ let mirroredQueue = queue;
+
+ if (next) {
+ if (originalOrder === null) originalOrder = queue.map(rntpTrackId);
+ const upcoming = queue.slice(activeIndex + 1);
+ if (upcoming.length > 1) {
+ const shuffledUpcoming = shuffleArray(upcoming);
+ await TrackPlayer.removeUpcomingTracks();
+ await TrackPlayer.add(shuffledUpcoming);
+ mirroredQueue = [...queue.slice(0, activeIndex + 1), ...shuffledUpcoming];
+ }
+ } else if (originalOrder) {
+ const byId = new Map(queue.map((t) => [rntpTrackId(t), t]));
+ const currentId = queue[activeIndex] ? rntpTrackId(queue[activeIndex]) : null;
+ const origPos = currentId ? originalOrder.indexOf(currentId) : -1;
+ const restoredIds = origPos >= 0 ? originalOrder.slice(origPos + 1) : originalOrder;
+ const restored = restoredIds
+ .map((id) => byId.get(id))
+ .filter((t): t is RntpTrack => Boolean(t));
+ await TrackPlayer.removeUpcomingTracks();
+ if (restored.length) await TrackPlayer.add(restored);
+ mirroredQueue = [...queue.slice(0, activeIndex + 1), ...restored];
+ }
+
+ useQueueStore.getState().setSnapshot(mirroredQueue, activeIndex);
+ store.setShuffle(next);
+}
+
+/** Insert a track right after the current one ("Play next"). */
+export async function enqueueTop(track: Track): Promise {
+ await ensurePlayerReady();
+ const activeIndex = await TrackPlayer.getActiveTrackIndex();
+ const activeTrack = await TrackPlayer.getActiveTrack();
+ const insertBefore = activeIndex === undefined ? undefined : activeIndex + 1;
+ const queueTrack = toRntpTrack(track);
+ await TrackPlayer.add(queueTrack, insertBefore);
+ if (useQueueStore.getState().hasSnapshot) {
+ useQueueStore.getState().insertTrack(queueTrack, insertBefore);
+ } else {
+ await useQueueStore.getState().refreshFromNative();
+ }
+ if (originalOrder) {
+ const currentId = activeTrack ? rntpTrackId(activeTrack) : null;
+ const pos = currentId ? originalOrder.indexOf(currentId) : -1;
+ if (pos >= 0) originalOrder.splice(pos + 1, 0, track.id);
+ else originalOrder.unshift(track.id);
+ }
+}
+
+/** Append a track to the end of the queue ("Add to queue"). */
+export async function enqueueEnd(track: Track): Promise {
+ await ensurePlayerReady();
+ const queueTrack = toRntpTrack(track);
+ await TrackPlayer.add(queueTrack);
+ if (useQueueStore.getState().hasSnapshot) {
+ useQueueStore.getState().insertTrack(queueTrack);
+ } else {
+ await useQueueStore.getState().refreshFromNative();
+ }
+ if (originalOrder) originalOrder.push(track.id);
+}
+
+// ── Queue-tray operations ────────────────────────────────────────────────────
+// The tray works in absolute RNTP queue indices. Single-item reorders use
+// RNTP's native move; group operations rebuild the upcoming tail so the current
+// track never stops.
+
+function moveOriginalOrderIfUnshuffled(fromIndex: number, toIndex: number): void {
+ if (usePlayerStore.getState().shuffle || originalOrder === null) return;
+ if (fromIndex < 0 || fromIndex >= originalOrder.length) return;
+ const [moved] = originalOrder.splice(fromIndex, 1);
+ const boundedTo = Math.max(0, Math.min(originalOrder.length, toIndex));
+ originalOrder.splice(boundedTo, 0, moved);
+}
+
+/** Replace everything after the current track with `upcoming` (in order). */
+export async function setUpcoming(upcoming: RntpTrack[]): Promise {
+ await TrackPlayer.removeUpcomingTracks();
+ if (upcoming.length) await TrackPlayer.add(upcoming);
+ useQueueStore.getState().replaceUpcoming(upcoming);
+ syncOriginalOrderFromMirrorIfUnshuffled();
+}
+
+/** Move a queued item by absolute RNTP queue index. */
+export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex: number): Promise {
+ if (fromAbsoluteIndex === toAbsoluteIndex) return;
+ await TrackPlayer.move(fromAbsoluteIndex, toAbsoluteIndex);
+ useQueueStore.getState().moveItem(fromAbsoluteIndex, toAbsoluteIndex);
+ moveOriginalOrderIfUnshuffled(fromAbsoluteIndex, toAbsoluteIndex);
+}
+
+/** Jump to (and play) an absolute queue index. */
+export async function jumpToQueueIndex(index: number): Promise {
+ await TrackPlayer.skip(index);
+ useQueueStore.getState().setActiveIndex(index);
+ await TrackPlayer.play();
+}
+
+async function getUpcoming(): Promise<{ activeIndex: number; upcoming: RntpTrack[] }> {
+ const { queue, activeIndex: active } = await getQueueSnapshot();
+ const activeIndex = active >= 0 ? active : -1;
+ return { activeIndex, upcoming: queue.slice(activeIndex + 1) };
+}
+
+/** Move an upcoming track (absolute index) to the front of the upcoming queue. */
+export async function requeueToTop(absoluteIndex: number): Promise {
+ const { activeIndex, upcoming } = await getUpcoming();
+ const local = absoluteIndex - (activeIndex + 1);
+ if (local < 0 || local >= upcoming.length) return;
+ const [moved] = upcoming.splice(local, 1);
+ upcoming.unshift(moved);
+ await setUpcoming(upcoming);
+}
+
+/** Move a group of upcoming tracks (absolute indices) to the front, order kept. */
+export async function requeueManyToTop(absoluteIndices: number[]): Promise {
+ const { activeIndex, upcoming } = await getUpcoming();
+ const locals = new Set(absoluteIndices.map((i) => i - (activeIndex + 1)));
+ const moved = upcoming.filter((_, i) => locals.has(i));
+ const rest = upcoming.filter((_, i) => !locals.has(i));
+ await setUpcoming([...moved, ...rest]);
+}
+
+/** Remove a single track at an absolute queue index. */
+export async function removeFromQueue(absoluteIndex: number): Promise {
+ await TrackPlayer.remove(absoluteIndex);
+ useQueueStore.getState().removeIndices([absoluteIndex]);
+ syncOriginalOrderFromMirrorIfUnshuffled();
+}
+
+/** Remove a group of tracks at absolute queue indices. */
+export async function removeManyFromQueue(absoluteIndices: number[]): Promise {
+ if (absoluteIndices.length === 0) return;
+ await TrackPlayer.remove(absoluteIndices);
+ useQueueStore.getState().removeIndices(absoluteIndices);
+ syncOriginalOrderFromMirrorIfUnshuffled();
+}
diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx
new file mode 100644
index 0000000..0f4cffd
--- /dev/null
+++ b/src/components/SwipeableRow.tsx
@@ -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 (
+
+ {swipeRight ? (
+
+
+
+ ) : null}
+ {swipeLeft ? (
+
+
+
+ ) : null}
+
+ {children}
+
+
+ );
+}
+
+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',
+ },
+});
diff --git a/src/components/library/TrackRow.tsx b/src/components/library/TrackRow.tsx
index 5f26115..3c0fb87 100644
--- a/src/components/library/TrackRow.tsx
+++ b/src/components/library/TrackRow.tsx
@@ -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(null);
@@ -33,7 +39,7 @@ export function TrackRow({
const thumbUri =
artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null;
- return (
+ const row = (
);
+
+ if (!swipeToQueue) return row;
+
+ return (
+ void enqueueTop(dbTrackToTrack(track)),
+ }}
+ swipeLeft={{
+ icon: 'list',
+ color: colors.bgTertiary,
+ iconColor: colors.accentText,
+ onCommit: () => void enqueueEnd(dbTrackToTrack(track)),
+ }}
+ >
+ {row}
+
+ );
}
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,
},
diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx
new file mode 100644
index 0000000..992367f
--- /dev/null
+++ b/src/components/queue/QueueTray.tsx
@@ -0,0 +1,1027 @@
+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();
+
+interface QueueEntry {
+ key: string;
+ identity: string;
+ track: RntpTrack;
+}
+
+type QueueIndexByKey = Record;
+
+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(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();
+ 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([]);
+ const [entries, setEntries] = useState([]);
+ 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({});
+
+ const clearDragState = useCallback(() => {
+ runOnUI(
+ (
+ active: SharedValue,
+ ty: SharedValue,
+ start: SharedValue,
+ target: SharedValue,
+ key: SharedValue,
+ settling: SharedValue
+ ) => {
+ 'worklet';
+ active.value = false;
+ settling.value = false;
+ ty.value = 0;
+ start.value = -1;
+ target.value = -1;
+ key.value = '';
+ }
+ )(dActive, dTy, dStart, dTarget, dKey, dSettling);
+ }, [dActive, dKey, dSettling, dStart, dTarget, dTy]);
+
+ const clearDragAfterReorderCommit = useCallback(() => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(clearDragState);
+ });
+ }, [clearDragState]);
+
+ const updateDragIndexMap = useCallback(
+ (indexMap: QueueIndexByKey) => {
+ runOnUI((sharedIndexMap: SharedValue, nextIndexMap: QueueIndexByKey) => {
+ 'worklet';
+ sharedIndexMap.value = nextIndexMap;
+ })(dIndexByKey, indexMap);
+ },
+ [dIndexByKey]
+ );
+
+ const setVisibleEntries = useCallback((nextEntries: QueueEntry[]) => {
+ entriesRef.current = nextEntries;
+ 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;
+ 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 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(dragArmHaptic)();
+ })
+ .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 = '';
+ });
+ 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]
+ );
+
+ const runAndRefresh = useCallback(
+ (task: Promise) => {
+ 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) => (
+
+ ),
+ []
+ );
+
+ const queueReady = hasSnapshot;
+ const isLoadingQueue = !queueReady;
+
+ const listExtraData = useMemo(
+ () => ({ editMode, selectedKeys: visibleSelectedKeys, queueReady }),
+ [editMode, queueReady, visibleSelectedKeys]
+ );
+
+ const renderItem = useCallback(
+ ({ item, index }: ListRenderItemInfo) => (
+
+ ),
+ [
+ dActive,
+ dIndexByKey,
+ dKey,
+ dSettling,
+ dStart,
+ dTarget,
+ dTy,
+ editMode,
+ entries.length,
+ 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]
+ );
+
+ const selectedCount = visibleSelectedKeys.size;
+ const canEdit = queueReady && entries.length > 0;
+
+ return (
+
+
+
+
+ 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
+
+
+ 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 ? (
+
+ [styles.actionBtn, pressed && styles.actionBtnPressed]}
+ onPress={groupPlayNext}
+ accessibilityRole="button"
+ accessibilityLabel={`Play ${selectedCount} selected songs next`}
+ >
+
+
+ Play next ({selectedCount})
+
+
+ [styles.actionBtn, pressed && styles.actionBtnPressed]}
+ onPress={groupRemove}
+ accessibilityRole="button"
+ accessibilityLabel={`Remove ${selectedCount} selected songs from queue`}
+ >
+
+
+ Remove ({selectedCount})
+
+
+
+ ) : null}
+
+ );
+}
+
+const Artwork = memo(function Artwork({ uri, title }: { uri?: string; title?: string }) {
+ return (
+
+ {uri ? (
+
+ ) : (
+
+ )}
+
+ );
+});
+
+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;
+ dTarget: SharedValue;
+ dTy: SharedValue;
+ dActive: SharedValue;
+ dKey: SharedValue;
+ dSettling: SharedValue;
+ dIndexByKey: SharedValue;
+ 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 = (
+
+ {editMode ? (
+
+
+
+ ) : null}
+
+
+
+ {title}
+
+
+ {artist}
+
+
+ {editMode ? (
+
+
+
+
+
+ ) : null}
+
+ );
+
+ if (!actionsEnabled) {
+ return (
+
+ [styles.rowPressable, pressed && styles.rowPressed]}
+ accessibilityRole="button"
+ accessibilityLabel={`Play ${title}`}
+ accessibilityHint="Opens this song in the queue"
+ >
+ {rowContent}
+
+
+ );
+ }
+
+ if (editMode) {
+ return (
+
+ [styles.rowPressable, pressed && styles.rowPressed]}
+ accessibilityRole="button"
+ accessibilityState={{ selected }}
+ accessibilityLabel={`${selected ? 'Deselect' : 'Select'} ${title}`}
+ >
+ {rowContent}
+
+
+ );
+ }
+
+ return (
+
+
+ [styles.rowPressable, pressed && styles.rowPressed]}
+ accessibilityRole="button"
+ accessibilityLabel={`Play ${title}`}
+ accessibilityHint="Opens this song in the queue"
+ >
+ {rowContent}
+
+
+
+ );
+});
+
+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;
diff --git a/src/components/queue/useQueue.ts b/src/components/queue/useQueue.ts
new file mode 100644
index 0000000..ba55123
--- /dev/null
+++ b/src/components/queue/useQueue.ts
@@ -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;
+}
+
+/**
+ * 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 };
+}
diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts
new file mode 100644
index 0000000..03c3c36
--- /dev/null
+++ b/src/lib/haptics.ts
@@ -0,0 +1,23 @@
+import * as Haptics from 'expo-haptics';
+
+/**
+ * Fire-and-forget haptic wrappers. Calls are best-effort — devices without a
+ * vibrator (or with system haptics disabled) reject silently. Keeping them here
+ * lets call sites stay declarative and makes the feedback vocabulary consistent
+ * across swipe rows and drag-reorder.
+ */
+
+/** Subtle tick at a gesture decision point (swipe arm/disarm). */
+export function tickHaptic(): void {
+ void Haptics.selectionAsync().catch(() => {});
+}
+
+/** Confirmation when a swipe commits to its action. */
+export function commitHaptic(): void {
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
+}
+
+/** Stronger bump when a hold-to-drag reorder engages. */
+export function dragArmHaptic(): void {
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
+}
diff --git a/src/stores/playerStore.ts b/src/stores/playerStore.ts
index 466dc89..6061be3 100644
--- a/src/stores/playerStore.ts
+++ b/src/stores/playerStore.ts
@@ -1,6 +1,8 @@
import { create } from 'zustand';
import type { PlaybackState, Track } from '@/types/audio';
+export type RepeatMode = 'none' | 'one' | 'all';
+
/**
* Player state — the UI's single source of truth, mirrored from the playback
* engine (RNTP at M0) by `usePlaybackSync`. Field names match desktop
@@ -14,12 +16,17 @@ interface PlayerStore {
duration: number;
volume: number; // 0–1
isMuted: boolean;
+ // Field names mirror desktop playerStore so queue/transport logic stays consistent.
+ shuffle: boolean;
+ repeat: RepeatMode;
setCurrentTrack: (track: Track | null) => void;
setPlaybackState: (state: PlaybackState) => void;
setProgress: (currentTime: number, duration: number) => void;
setVolume: (volume: number) => void;
setMuted: (isMuted: boolean) => void;
+ setShuffle: (shuffle: boolean) => void;
+ setRepeat: (repeat: RepeatMode) => void;
reset: () => void;
}
@@ -30,12 +37,16 @@ export const usePlayerStore = create((set) => ({
duration: 0,
volume: 1,
isMuted: false,
+ shuffle: false,
+ repeat: 'none',
setCurrentTrack: (currentTrack) => set({ currentTrack }),
setPlaybackState: (playbackState) => set({ playbackState }),
setProgress: (currentTime, duration) => set({ currentTime, duration }),
setVolume: (volume) => set({ volume }),
setMuted: (isMuted) => set({ isMuted }),
+ setShuffle: (shuffle) => set({ shuffle }),
+ setRepeat: (repeat) => set({ repeat }),
reset: () =>
set({ currentTrack: null, playbackState: 'stopped', currentTime: 0, duration: 0 }),
}));
diff --git a/src/stores/playlistStore.ts b/src/stores/playlistStore.ts
index ae06974..5c55b7f 100644
--- a/src/stores/playlistStore.ts
+++ b/src/stores/playlistStore.ts
@@ -46,7 +46,8 @@ interface PlaylistStore {
addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise;
removeFromPlaylist: (id: number, trackPath: string) => Promise;
moveTrack: (id: number, trackPath: string, direction: -1 | 1) => Promise;
- toggleFavorite: (track: DbTrack) => Promise;
+ // Only the path is read; accepts a library DbTrack or the now-playing Track.
+ toggleFavorite: (track: { path: string }) => Promise;
markPlayed: (id: number) => Promise;
importM3u: () => Promise;
exportM3u: (target: number | 'favorites') => Promise;
diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts
new file mode 100644
index 0000000..c8c79f6
--- /dev/null
+++ b/src/stores/queueStore.ts
@@ -0,0 +1,125 @@
+import { create } from 'zustand';
+import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player';
+
+/**
+ * Live mirror of RNTP's native queue for the queue tray. Playback actions keep
+ * this in sync so opening the tray can render from JS immediately instead of
+ * marshaling a very large native queue across the bridge on the cold path.
+ */
+interface QueueStore {
+ tracks: RntpTrack[];
+ activeIndex: number;
+ hasSnapshot: boolean;
+ refreshFromNative: () => Promise;
+ refreshActiveIndex: () => Promise;
+ setSnapshot: (tracks: RntpTrack[], activeIndex?: number) => void;
+ setActiveIndex: (activeIndex: number) => void;
+ insertTrack: (track: RntpTrack, index?: number) => void;
+ replaceUpcoming: (upcoming: RntpTrack[]) => void;
+ moveItem: (fromIndex: number, toIndex: number) => void;
+ removeIndices: (indices: number[]) => void;
+}
+
+function normalizeActiveIndex(activeIndex: number | undefined, trackCount: number): number {
+ if (activeIndex == null || activeIndex < 0 || activeIndex >= trackCount) return -1;
+ return activeIndex;
+}
+
+function boundedInsertIndex(index: number | undefined, length: number): number {
+ if (index == null) return length;
+ return Math.max(0, Math.min(length, index));
+}
+
+export const useQueueStore = create((set) => ({
+ tracks: [],
+ activeIndex: -1,
+ hasSnapshot: false,
+ refreshFromNative: async () => {
+ const [tracks, activeIndex] = await Promise.all([
+ TrackPlayer.getQueue(),
+ TrackPlayer.getActiveTrackIndex(),
+ ]);
+ set({
+ tracks,
+ activeIndex: normalizeActiveIndex(activeIndex, tracks.length),
+ hasSnapshot: true,
+ });
+ },
+ refreshActiveIndex: async () => {
+ const activeIndex = await TrackPlayer.getActiveTrackIndex();
+ set((s) => ({ activeIndex: normalizeActiveIndex(activeIndex, s.tracks.length) }));
+ },
+ setSnapshot: (tracks, activeIndex = 0) =>
+ set({
+ tracks,
+ activeIndex: normalizeActiveIndex(activeIndex, tracks.length),
+ hasSnapshot: true,
+ }),
+ setActiveIndex: (activeIndex) =>
+ set((s) => ({ activeIndex: normalizeActiveIndex(activeIndex, s.tracks.length) })),
+ insertTrack: (track, index) =>
+ set((s) => {
+ const insertAt = boundedInsertIndex(index, s.tracks.length);
+ const tracks = [...s.tracks];
+ tracks.splice(insertAt, 0, track);
+ const activeIndex = s.activeIndex >= insertAt ? s.activeIndex + 1 : s.activeIndex;
+ return { tracks, activeIndex, hasSnapshot: true };
+ }),
+ replaceUpcoming: (upcoming) =>
+ set((s) => {
+ const prefixEnd = s.activeIndex >= 0 ? s.activeIndex + 1 : 0;
+ return {
+ tracks: [...s.tracks.slice(0, prefixEnd), ...upcoming],
+ hasSnapshot: true,
+ };
+ }),
+ moveItem: (fromIndex, toIndex) =>
+ set((s) => {
+ if (fromIndex === toIndex || fromIndex < 0 || fromIndex >= s.tracks.length) return s;
+
+ const tracks = [...s.tracks];
+ const [moved] = tracks.splice(fromIndex, 1);
+ const insertAt = boundedInsertIndex(toIndex, tracks.length);
+ tracks.splice(insertAt, 0, moved);
+
+ let activeIndex = s.activeIndex;
+ if (activeIndex === fromIndex) {
+ activeIndex = insertAt;
+ } else if (fromIndex < activeIndex && insertAt >= activeIndex) {
+ activeIndex -= 1;
+ } else if (fromIndex > activeIndex && insertAt <= activeIndex) {
+ activeIndex += 1;
+ }
+
+ return { tracks, activeIndex, hasSnapshot: true };
+ }),
+ removeIndices: (indices) =>
+ set((s) => {
+ if (indices.length === 0 || s.tracks.length === 0) return s;
+
+ const removeSet = new Set(
+ indices.filter((index) => index >= 0 && index < s.tracks.length)
+ );
+ if (removeSet.size === 0) return s;
+
+ const tracks = s.tracks.filter((_, index) => !removeSet.has(index));
+ let activeIndex = s.activeIndex;
+ if (activeIndex >= 0) {
+ if (removeSet.has(activeIndex)) {
+ activeIndex = tracks.length > 0 ? Math.min(activeIndex, tracks.length - 1) : -1;
+ } else {
+ let removedBeforeActive = 0;
+ removeSet.forEach((index) => {
+ if (index < activeIndex) removedBeforeActive += 1;
+ });
+ activeIndex -= removedBeforeActive;
+ }
+ }
+
+ return {
+ tracks,
+ activeIndex: normalizeActiveIndex(activeIndex, tracks.length),
+ hasSnapshot: true,
+ };
+ }),
+}));
diff --git a/src/theme/motion.ts b/src/theme/motion.ts
new file mode 100644
index 0000000..24f402a
--- /dev/null
+++ b/src/theme/motion.ts
@@ -0,0 +1,13 @@
+import { Easing } from 'react-native-reanimated';
+
+/**
+ * Shared motion curves. Deliberately spring-free: plain ease-out timing so
+ * sheets, rows, and snaps settle smoothly without overshoot/bounce (which read
+ * as distracting on transport/queue UI). Use these instead of `withSpring`.
+ */
+export const motion = {
+ /** Small, fast settle — swipe spring-back, row snaps. */
+ quick: { duration: 160, easing: Easing.out(Easing.cubic) },
+ /** Sheet / snap-point transitions. */
+ snap: { duration: 220, easing: Easing.out(Easing.cubic) },
+} as const;