mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
queue panel
This commit is contained in:
Generated
+50
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fisher–Yates 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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<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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { colors } from '@/theme';
|
||||||
|
import { motion } from '@/theme/motion';
|
||||||
|
import { commitHaptic, tickHaptic } from '@/lib/haptics';
|
||||||
|
|
||||||
|
type IconName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
|
export interface SwipeAction {
|
||||||
|
icon: IconName;
|
||||||
|
/** Background of the revealed action lane. */
|
||||||
|
color: string;
|
||||||
|
iconColor?: string;
|
||||||
|
onCommit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SwipeableRowProps {
|
||||||
|
/** Revealed on the LEFT as the row is dragged right (a rightward swipe). */
|
||||||
|
swipeRight?: SwipeAction;
|
||||||
|
/** Revealed on the RIGHT as the row is dragged left (a leftward swipe). */
|
||||||
|
swipeLeft?: SwipeAction;
|
||||||
|
/**
|
||||||
|
* Optional vertical gesture (e.g. a hold-to-drag reorder) raced against the
|
||||||
|
* horizontal swipe in the SAME detector — composing here avoids the nested
|
||||||
|
* GestureDetectors that broke continuous hold-and-drag.
|
||||||
|
*/
|
||||||
|
dragGesture?: GestureType;
|
||||||
|
enabled?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontally swipeable row. Translation is clamped to ±width/2; a haptic tick
|
||||||
|
* fires when crossing the ±width/4 "arm" point in either direction (arming to
|
||||||
|
* commit, or backing off). Releasing past the arm point runs the matching
|
||||||
|
* action; otherwise the row springs back. Vertical drags fall through so the row
|
||||||
|
* still scrolls / lets a parent sheet pan.
|
||||||
|
*/
|
||||||
|
export function SwipeableRow({
|
||||||
|
swipeRight,
|
||||||
|
swipeLeft,
|
||||||
|
dragGesture,
|
||||||
|
enabled = true,
|
||||||
|
children,
|
||||||
|
}: SwipeableRowProps) {
|
||||||
|
const tx = useSharedValue(0);
|
||||||
|
const armed = useSharedValue(false);
|
||||||
|
const [rowWidth, setRowWidth] = useState(0);
|
||||||
|
|
||||||
|
const max = rowWidth / 2;
|
||||||
|
const arm = rowWidth / 4;
|
||||||
|
const hasRight = !!swipeRight;
|
||||||
|
const hasLeft = !!swipeLeft;
|
||||||
|
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => setRowWidth(e.nativeEvent.layout.width);
|
||||||
|
|
||||||
|
const onCommit = (direction: 'right' | 'left') => {
|
||||||
|
if (direction === 'right') swipeRight?.onCommit();
|
||||||
|
else swipeLeft?.onCommit();
|
||||||
|
commitHaptic();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pan = Gesture.Pan()
|
||||||
|
.enabled(enabled && rowWidth > 0 && (hasRight || hasLeft))
|
||||||
|
.activeOffsetX([-12, 12])
|
||||||
|
.failOffsetY([-12, 12])
|
||||||
|
.onUpdate((e) => {
|
||||||
|
let t = e.translationX;
|
||||||
|
if (t > 0 && !hasRight) t = 0;
|
||||||
|
if (t < 0 && !hasLeft) t = 0;
|
||||||
|
t = Math.max(-max, Math.min(max, t));
|
||||||
|
tx.value = t;
|
||||||
|
const nowArmed = Math.abs(t) >= arm;
|
||||||
|
if (nowArmed !== armed.value) {
|
||||||
|
armed.value = nowArmed;
|
||||||
|
runOnJS(tickHaptic)();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
const t = tx.value;
|
||||||
|
if (t >= arm && hasRight) runOnJS(onCommit)('right');
|
||||||
|
else if (t <= -arm && hasLeft) runOnJS(onCommit)('left');
|
||||||
|
armed.value = false;
|
||||||
|
tx.value = withTiming(0, motion.quick);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Race so a horizontal swipe and a (long-press) vertical drag never fight:
|
||||||
|
// whichever activates first wins and cancels the other.
|
||||||
|
const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan;
|
||||||
|
|
||||||
|
const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
|
||||||
|
const leftLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value > 1 ? 1 : 0 }));
|
||||||
|
const rightLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value < -1 ? 1 : 0 }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrap} onLayout={onLayout}>
|
||||||
|
{swipeRight ? (
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[styles.lane, styles.laneLeft, { backgroundColor: swipeRight.color }, leftLaneStyle]}
|
||||||
|
>
|
||||||
|
<Ionicons name={swipeRight.icon} size={22} color={swipeRight.iconColor ?? colors.bgPrimary} />
|
||||||
|
</Animated.View>
|
||||||
|
) : null}
|
||||||
|
{swipeLeft ? (
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[styles.lane, styles.laneRight, { backgroundColor: swipeLeft.color }, rightLaneStyle]}
|
||||||
|
>
|
||||||
|
<Ionicons name={swipeLeft.icon} size={22} color={swipeLeft.iconColor ?? colors.bgPrimary} />
|
||||||
|
</Animated.View>
|
||||||
|
) : null}
|
||||||
|
<GestureDetector gesture={gesture}>
|
||||||
|
<Animated.View style={contentStyle}>{children}</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: {
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
lane: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
laneLeft: {
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
},
|
||||||
|
laneRight: {
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,9 +4,12 @@ import { Image } from 'expo-image';
|
|||||||
import { Text } from '@/components/Text';
|
import { 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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
@@ -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; // 0–1
|
volume: number; // 0–1
|
||||||
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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user