queue panel

This commit is contained in:
Boof2015
2026-06-18 12:58:09 -04:00
parent 97522ea5cf
commit 5853edd933
13 changed files with 1807 additions and 38 deletions
+50
View File
@@ -13,6 +13,7 @@
"@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@expo/ui": "~56.0.13", "@expo/ui": "~56.0.13",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.14",
"@op-engineering/op-sqlite": "^16.2.1", "@op-engineering/op-sqlite": "^16.2.1",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@shopify/react-native-skia": "2.6.2", "@shopify/react-native-skia": "2.6.2",
@@ -25,6 +26,7 @@
"expo-file-system": "~56.0.8", "expo-file-system": "~56.0.8",
"expo-font": "~56.0.5", "expo-font": "~56.0.5",
"expo-glass-effect": "~56.0.4", "expo-glass-effect": "~56.0.4",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.9", "expo-image": "~56.0.9",
"expo-linking": "~56.0.11", "expo-linking": "~56.0.11",
"expo-router": "~56.2.6", "expo-router": "~56.2.6",
@@ -1961,6 +1963,45 @@
"excpretty": "build/cli.js" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -6237,6 +6278,15 @@
"react-native": "*" "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": { "node_modules/expo-image": {
"version": "56.0.9", "version": "56.0.9",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-56.0.9.tgz", "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-56.0.9.tgz",
+2
View File
@@ -7,6 +7,7 @@
"@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@expo/ui": "~56.0.13", "@expo/ui": "~56.0.13",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.14",
"@op-engineering/op-sqlite": "^16.2.1", "@op-engineering/op-sqlite": "^16.2.1",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@shopify/react-native-skia": "2.6.2", "@shopify/react-native-skia": "2.6.2",
@@ -19,6 +20,7 @@
"expo-file-system": "~56.0.8", "expo-file-system": "~56.0.8",
"expo-font": "~56.0.5", "expo-font": "~56.0.5",
"expo-glass-effect": "~56.0.4", "expo-glass-effect": "~56.0.4",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.9", "expo-image": "~56.0.9",
"expo-linking": "~56.0.11", "expo-linking": "~56.0.11",
"expo-router": "~56.2.6", "expo-router": "~56.2.6",
+77 -25
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native'; import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
@@ -11,25 +11,26 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withSpring, withSpring,
withTiming,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo'; import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge'; import { FormatBadges } from '@/components/FormatBadge';
import { WaveformSeekBar } from '@/components/WaveformSeekBar'; import { WaveformSeekBar } from '@/components/WaveformSeekBar';
import { Visualizer } from '@/components/Visualizer'; import { Visualizer } from '@/components/Visualizer';
import { QueueTray } from '@/components/queue/QueueTray';
import { colors, radius, spacing } from '@/theme'; import { colors, radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { seekTo, skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController'; import { usePlaylistStore } from '@/stores/playlistStore';
import {
type IconName = keyof typeof Ionicons.glyphMap; cycleRepeat,
seekTo,
// Secondary controls are placeholders for now — laid out to settle the design. skipToNext,
const SUB_CONTROLS: { icon: IconName; label: string }[] = [ skipToPrevious,
{ icon: 'shuffle', label: 'Shuffle' }, togglePlay,
{ icon: 'heart-outline', label: 'Favorite' }, toggleShuffle,
{ icon: 'list-outline', label: 'Queue' }, } from '@/audio/playbackController';
{ icon: 'repeat', label: 'Repeat' },
];
const DISMISS_DISTANCE = 140; const DISMISS_DISTANCE = 140;
const DISMISS_VELOCITY = 1000; const DISMISS_VELOCITY = 1000;
@@ -128,10 +129,15 @@ export default function NowPlayingScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [showScopeStage, setShowScopeStage] = useState(false); const [showScopeStage, setShowScopeStage] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const track = usePlayerStore((s) => s.currentTrack); const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState); const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime); const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration); 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 isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading'; const isLoading = playbackState === 'loading';
@@ -182,7 +188,7 @@ export default function NowPlayingScreen() {
} }
); );
} else { } 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 && ( {layout.showSecondaryControls && (
<View style={styles.subRow}> <View style={styles.subRow}>
{SUB_CONTROLS.map((c) => ( <Pressable
<Pressable hitSlop={10}
key={c.label} style={styles.subBtn}
hitSlop={10} onPress={() => void toggleShuffle()}
style={styles.subBtn} accessibilityLabel="Shuffle"
accessibilityLabel={c.label} accessibilityState={{ selected: shuffle }}
> >
<Ionicons <Ionicons
name={c.icon} name="shuffle"
size={SUB_ICON_SIZE}
color={shuffle ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => void toggleFavorite(track)}
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
accessibilityState={{ selected: isFavorite }}
>
<Ionicons
name={isFavorite ? 'heart' : 'heart-outline'}
size={SUB_ICON_SIZE}
color={isFavorite ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => setQueueOpen(true)}
accessibilityLabel="Queue"
>
<Ionicons
name="list-outline"
size={SUB_ICON_SIZE}
color={colors.textTertiary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => void cycleRepeat()}
accessibilityLabel="Repeat"
accessibilityState={{ selected: repeat !== 'none' }}
>
{repeat === 'one' ? (
<MaterialCommunityIcons
name="repeat-once"
size={SUB_ICON_SIZE} size={SUB_ICON_SIZE}
color={colors.textTertiary} color={colors.accent}
/> />
</Pressable> ) : (
))} <Ionicons
name="repeat"
size={SUB_ICON_SIZE}
color={repeat === 'all' ? colors.accent : colors.textTertiary}
/>
)}
</Pressable>
</View> </View>
)} )}
</View> </View>
@@ -363,6 +414,7 @@ export default function NowPlayingScreen() {
</View> </View>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
{queueOpen && <QueueTray onClose={() => setQueueOpen(false)} />}
</View> </View>
); );
} }
+262 -11
View File
@@ -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 type { Track } from '@/types/audio';
import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
import { useQueueStore } from '@/stores/queueStore';
import { setupPlayer } from './trackPlayer'; import { setupPlayer } from './trackPlayer';
import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks'; import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
@@ -9,35 +15,113 @@ import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
* would slot in behind the same function signatures. * 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<RepeatModeStr, RepeatModeStr> = {
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<void> {
await useQueueStore.getState().refreshActiveIndex();
}
function syncOriginalOrderFromMirrorIfUnshuffled(): void {
if (usePlayerStore.getState().shuffle) return;
const { tracks, hasSnapshot } = useQueueStore.getState();
if (hasSnapshot) originalOrder = tracks.map(rntpTrackId);
}
/** FisherYates shuffle a copy of the array. */
function shuffleArray<T>(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 * 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 * 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 * 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<void> { async function ensurePlayerReady(): Promise<void> {
await setupPlayer(); await setupPlayer();
await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat));
} }
/** Replace the queue with the given tracks and start playing at startIndex. */ /** Replace the queue with the given tracks and start playing at startIndex. */
export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> { export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> {
if (tracks.length === 0) return; if (tracks.length === 0) return;
await ensurePlayerReady(); 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) { if (startIndex > 0) {
await TrackPlayer.skip(startIndex); 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(); await TrackPlayer.play();
} }
/** FisherYates 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<void> { export async function shuffleTracks(tracks: Track[]): Promise<void> {
const shuffled = [...tracks]; if (tracks.length === 0) return;
for (let i = shuffled.length - 1; i > 0; i--) { await ensurePlayerReady();
const j = Math.floor(Math.random() * (i + 1)); originalOrder = tracks.map((t) => t.id);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; usePlayerStore.getState().setShuffle(true);
} const queueTracks = shuffleArray(tracks).map(toRntpTrack);
await playTracks(shuffled); 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. */ /** M0 demo entry point: load the streamed sample queue if nothing is queued. */
@@ -45,7 +129,13 @@ export async function playSample(): Promise<void> {
await ensurePlayerReady(); await ensurePlayerReady();
const queue = await TrackPlayer.getQueue(); const queue = await TrackPlayer.getQueue();
if (queue.length === 0) { 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(); await TrackPlayer.play();
} }
@@ -67,6 +157,7 @@ export async function togglePlay(): Promise<void> {
export async function skipToNext(): Promise<void> { export async function skipToNext(): Promise<void> {
try { try {
await TrackPlayer.skipToNext(); await TrackPlayer.skipToNext();
await refreshActiveIndexFromNative();
} catch { } catch {
// no next track — ignore // no next track — ignore
} }
@@ -75,7 +166,167 @@ export async function skipToNext(): Promise<void> {
export async function skipToPrevious(): Promise<void> { export async function skipToPrevious(): Promise<void> {
try { try {
await TrackPlayer.skipToPrevious(); await TrackPlayer.skipToPrevious();
await refreshActiveIndexFromNative();
} catch { } catch {
// no previous track — ignore // no previous track — ignore
} }
} }
/** Cycle repeat none → all → one (desktop order) and push it to RNTP. */
export async function cycleRepeat(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
if (absoluteIndices.length === 0) return;
await TrackPlayer.remove(absoluteIndices);
useQueueStore.getState().removeIndices(absoluteIndices);
syncOriginalOrderFromMirrorIfUnshuffled();
}
+149
View File
@@ -0,0 +1,149 @@
import { useState, type ReactNode } from 'react';
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { colors } from '@/theme';
import { motion } from '@/theme/motion';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
type IconName = keyof typeof Ionicons.glyphMap;
export interface SwipeAction {
icon: IconName;
/** Background of the revealed action lane. */
color: string;
iconColor?: string;
onCommit: () => void;
}
interface SwipeableRowProps {
/** Revealed on the LEFT as the row is dragged right (a rightward swipe). */
swipeRight?: SwipeAction;
/** Revealed on the RIGHT as the row is dragged left (a leftward swipe). */
swipeLeft?: SwipeAction;
/**
* Optional vertical gesture (e.g. a hold-to-drag reorder) raced against the
* horizontal swipe in the SAME detector — composing here avoids the nested
* GestureDetectors that broke continuous hold-and-drag.
*/
dragGesture?: GestureType;
enabled?: boolean;
children: ReactNode;
}
/**
* Horizontally swipeable row. Translation is clamped to ±width/2; a haptic tick
* fires when crossing the ±width/4 "arm" point in either direction (arming to
* commit, or backing off). Releasing past the arm point runs the matching
* action; otherwise the row springs back. Vertical drags fall through so the row
* still scrolls / lets a parent sheet pan.
*/
export function SwipeableRow({
swipeRight,
swipeLeft,
dragGesture,
enabled = true,
children,
}: SwipeableRowProps) {
const tx = useSharedValue(0);
const armed = useSharedValue(false);
const [rowWidth, setRowWidth] = useState(0);
const max = rowWidth / 2;
const arm = rowWidth / 4;
const hasRight = !!swipeRight;
const hasLeft = !!swipeLeft;
const onLayout = (e: LayoutChangeEvent) => setRowWidth(e.nativeEvent.layout.width);
const onCommit = (direction: 'right' | 'left') => {
if (direction === 'right') swipeRight?.onCommit();
else swipeLeft?.onCommit();
commitHaptic();
};
const pan = Gesture.Pan()
.enabled(enabled && rowWidth > 0 && (hasRight || hasLeft))
.activeOffsetX([-12, 12])
.failOffsetY([-12, 12])
.onUpdate((e) => {
let t = e.translationX;
if (t > 0 && !hasRight) t = 0;
if (t < 0 && !hasLeft) t = 0;
t = Math.max(-max, Math.min(max, t));
tx.value = t;
const nowArmed = Math.abs(t) >= arm;
if (nowArmed !== armed.value) {
armed.value = nowArmed;
runOnJS(tickHaptic)();
}
})
.onEnd(() => {
const t = tx.value;
if (t >= arm && hasRight) runOnJS(onCommit)('right');
else if (t <= -arm && hasLeft) runOnJS(onCommit)('left');
armed.value = false;
tx.value = withTiming(0, motion.quick);
});
// Race so a horizontal swipe and a (long-press) vertical drag never fight:
// whichever activates first wins and cancels the other.
const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan;
const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
const leftLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value > 1 ? 1 : 0 }));
const rightLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value < -1 ? 1 : 0 }));
return (
<View style={styles.wrap} onLayout={onLayout}>
{swipeRight ? (
<Animated.View
pointerEvents="none"
style={[styles.lane, styles.laneLeft, { backgroundColor: swipeRight.color }, leftLaneStyle]}
>
<Ionicons name={swipeRight.icon} size={22} color={swipeRight.iconColor ?? colors.bgPrimary} />
</Animated.View>
) : null}
{swipeLeft ? (
<Animated.View
pointerEvents="none"
style={[styles.lane, styles.laneRight, { backgroundColor: swipeLeft.color }, rightLaneStyle]}
>
<Ionicons name={swipeLeft.icon} size={22} color={swipeLeft.iconColor ?? colors.bgPrimary} />
</Animated.View>
) : null}
<GestureDetector gesture={gesture}>
<Animated.View style={contentStyle}>{children}</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
wrap: {
position: 'relative',
overflow: 'hidden',
},
lane: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
laneLeft: {
justifyContent: 'flex-start',
},
laneRight: {
justifyContent: 'flex-end',
},
});
+29 -1
View File
@@ -4,9 +4,12 @@ import { Image } from 'expo-image';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo'; import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge'; import { FormatBadges } from '@/components/FormatBadge';
import { SwipeableRow } from '@/components/SwipeableRow';
import { colors, radius, spacing } from '@/theme'; import { colors, radius, spacing } from '@/theme';
import { formatDuration } from '@/lib/format'; import { formatDuration } from '@/lib/format';
import { artworkThumbUri } from '@/library/artwork'; import { artworkThumbUri } from '@/library/artwork';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { enqueueEnd, enqueueTop } from '@/audio/playbackController';
import type { DbTrack } from '@/types/library'; import type { DbTrack } from '@/types/library';
const ART_SIZE = 44; const ART_SIZE = 44;
@@ -18,6 +21,7 @@ export function TrackRow({
onLongPress, onLongPress,
showArtist = true, showArtist = true,
active = false, active = false,
swipeToQueue = true,
}: { }: {
track: DbTrack; track: DbTrack;
onPress: () => void; onPress: () => void;
@@ -26,6 +30,8 @@ export function TrackRow({
/** Hide on album detail where every row shares the artist. */ /** Hide on album detail where every row shares the artist. */
showArtist?: boolean; showArtist?: boolean;
active?: 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 artworkHash = track.artwork_hash;
const [failedArtworkHash, setFailedArtworkHash] = useState<string | null>(null); const [failedArtworkHash, setFailedArtworkHash] = useState<string | null>(null);
@@ -33,7 +39,7 @@ export function TrackRow({
const thumbUri = const thumbUri =
artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null; artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null;
return ( const row = (
<Pressable <Pressable
style={styles.row} style={styles.row}
onPress={onPress} onPress={onPress}
@@ -92,6 +98,26 @@ export function TrackRow({
</Text> </Text>
</Pressable> </Pressable>
); );
if (!swipeToQueue) return row;
return (
<SwipeableRow
swipeRight={{
icon: 'play',
color: colors.accent,
onCommit: () => void enqueueTop(dbTrackToTrack(track)),
}}
swipeLeft={{
icon: 'list',
color: colors.bgTertiary,
iconColor: colors.accentText,
onCommit: () => void enqueueEnd(dbTrackToTrack(track)),
}}
>
{row}
</SwipeableRow>
);
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@@ -101,6 +127,8 @@ const styles = StyleSheet.create({
minHeight: ROW_MIN_HEIGHT, minHeight: ROW_MIN_HEIGHT,
paddingVertical: spacing.sm + 2, paddingVertical: spacing.sm + 2,
gap: spacing.md, gap: spacing.md,
// Opaque so the swipe action lane only shows where the row has slid away.
backgroundColor: colors.bgPrimary,
borderBottomColor: colors.glassBorder, borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { Event, useTrackPlayerEvents, type Track as RntpTrack } from 'react-native-track-player';
import { useQueueStore } from '@/stores/queueStore';
export interface QueueSnapshot {
tracks: RntpTrack[];
activeIndex: number;
hasSnapshot: boolean;
refresh: () => Promise<void>;
}
/**
* Live view of the JS queue mirror. Opening the tray only falls back to RNTP's
* full native queue read when no playback action has populated the mirror yet.
*/
export function useQueue(active: boolean): QueueSnapshot {
const tracks = useQueueStore((s) => s.tracks);
const activeIndex = useQueueStore((s) => s.activeIndex);
const hasSnapshot = useQueueStore((s) => s.hasSnapshot);
const refresh = useQueueStore((s) => s.refreshFromNative);
const refreshActiveIndex = useQueueStore((s) => s.refreshActiveIndex);
const setActiveIndex = useQueueStore((s) => s.setActiveIndex);
useEffect(() => {
if (!active) return;
if (hasSnapshot) void refreshActiveIndex();
else void refresh();
}, [active, hasSnapshot, refresh, refreshActiveIndex]);
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], (event) => {
if (!active) return;
if (hasSnapshot) setActiveIndex(event.index ?? -1);
else void refresh();
});
return { tracks, activeIndex, hasSnapshot, refresh };
}
+23
View File
@@ -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(() => {});
}
+11
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { PlaybackState, Track } from '@/types/audio'; 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 * Player state — the UI's single source of truth, mirrored from the playback
* engine (RNTP at M0) by `usePlaybackSync`. Field names match desktop * engine (RNTP at M0) by `usePlaybackSync`. Field names match desktop
@@ -14,12 +16,17 @@ interface PlayerStore {
duration: number; duration: number;
volume: number; // 01 volume: number; // 01
isMuted: boolean; isMuted: boolean;
// Field names mirror desktop playerStore so queue/transport logic stays consistent.
shuffle: boolean;
repeat: RepeatMode;
setCurrentTrack: (track: Track | null) => void; setCurrentTrack: (track: Track | null) => void;
setPlaybackState: (state: PlaybackState) => void; setPlaybackState: (state: PlaybackState) => void;
setProgress: (currentTime: number, duration: number) => void; setProgress: (currentTime: number, duration: number) => void;
setVolume: (volume: number) => void; setVolume: (volume: number) => void;
setMuted: (isMuted: boolean) => void; setMuted: (isMuted: boolean) => void;
setShuffle: (shuffle: boolean) => void;
setRepeat: (repeat: RepeatMode) => void;
reset: () => void; reset: () => void;
} }
@@ -30,12 +37,16 @@ export const usePlayerStore = create<PlayerStore>((set) => ({
duration: 0, duration: 0,
volume: 1, volume: 1,
isMuted: false, isMuted: false,
shuffle: false,
repeat: 'none',
setCurrentTrack: (currentTrack) => set({ currentTrack }), setCurrentTrack: (currentTrack) => set({ currentTrack }),
setPlaybackState: (playbackState) => set({ playbackState }), setPlaybackState: (playbackState) => set({ playbackState }),
setProgress: (currentTime, duration) => set({ currentTime, duration }), setProgress: (currentTime, duration) => set({ currentTime, duration }),
setVolume: (volume) => set({ volume }), setVolume: (volume) => set({ volume }),
setMuted: (isMuted) => set({ isMuted }), setMuted: (isMuted) => set({ isMuted }),
setShuffle: (shuffle) => set({ shuffle }),
setRepeat: (repeat) => set({ repeat }),
reset: () => reset: () =>
set({ currentTrack: null, playbackState: 'stopped', currentTime: 0, duration: 0 }), set({ currentTrack: null, playbackState: 'stopped', currentTime: 0, duration: 0 }),
})); }));
+2 -1
View File
@@ -46,7 +46,8 @@ interface PlaylistStore {
addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise<number>; addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise<number>;
removeFromPlaylist: (id: number, trackPath: string) => Promise<void>; removeFromPlaylist: (id: number, trackPath: string) => Promise<void>;
moveTrack: (id: number, trackPath: string, direction: -1 | 1) => Promise<void>; moveTrack: (id: number, trackPath: string, direction: -1 | 1) => Promise<void>;
toggleFavorite: (track: DbTrack) => Promise<void>; // Only the path is read; accepts a library DbTrack or the now-playing Track.
toggleFavorite: (track: { path: string }) => Promise<void>;
markPlayed: (id: number) => Promise<void>; markPlayed: (id: number) => Promise<void>;
importM3u: () => Promise<M3uImportSummary | null>; importM3u: () => Promise<M3uImportSummary | null>;
exportM3u: (target: number | 'favorites') => Promise<M3uExportResult | null>; exportM3u: (target: number | 'favorites') => Promise<M3uExportResult | null>;
+125
View File
@@ -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<void>;
refreshActiveIndex: () => Promise<void>;
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<QueueStore>((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,
};
}),
}));
+13
View File
@@ -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;