From 5853edd933e0da773ebe4d1ec2ce70fc9e99ba6d Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:58:09 -0400 Subject: [PATCH] queue panel --- package-lock.json | 50 ++ package.json | 2 + src/app/now-playing.tsx | 102 ++- src/audio/playbackController.ts | 273 ++++++- src/components/SwipeableRow.tsx | 149 ++++ src/components/library/TrackRow.tsx | 30 +- src/components/queue/QueueTray.tsx | 1027 +++++++++++++++++++++++++++ src/components/queue/useQueue.ts | 37 + src/lib/haptics.ts | 23 + src/stores/playerStore.ts | 11 + src/stores/playlistStore.ts | 3 +- src/stores/queueStore.ts | 125 ++++ src/theme/motion.ts | 13 + 13 files changed, 1807 insertions(+), 38 deletions(-) create mode 100644 src/components/SwipeableRow.tsx create mode 100644 src/components/queue/QueueTray.tsx create mode 100644 src/components/queue/useQueue.ts create mode 100644 src/lib/haptics.ts create mode 100644 src/stores/queueStore.ts create mode 100644 src/theme/motion.ts 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;