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/ui": "~56.0.13",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@op-engineering/op-sqlite": "^16.2.1",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
@@ -25,6 +26,7 @@
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-font": "~56.0.5",
|
||||
"expo-glass-effect": "~56.0.4",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.9",
|
||||
"expo-linking": "~56.0.11",
|
||||
"expo-router": "~56.2.6",
|
||||
@@ -1961,6 +1963,45 @@
|
||||
"excpretty": "build/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@gorhom/bottom-sheet": {
|
||||
"version": "5.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz",
|
||||
"integrity": "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gorhom/portal": "1.0.14",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-native": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-gesture-handler": ">=2.16.1",
|
||||
"react-native-reanimated": ">=3.16.0 || >=4.0.0-"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@gorhom/portal": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz",
|
||||
"integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -6237,6 +6278,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-haptics": {
|
||||
"version": "56.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-56.0.3.tgz",
|
||||
"integrity": "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image": {
|
||||
"version": "56.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-56.0.9.tgz",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
|
||||
"@expo/ui": "~56.0.13",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@op-engineering/op-sqlite": "^16.2.1",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
@@ -19,6 +20,7 @@
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-font": "~56.0.5",
|
||||
"expo-glass-effect": "~56.0.4",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.9",
|
||||
"expo-linking": "~56.0.11",
|
||||
"expo-router": "~56.2.6",
|
||||
|
||||
+77
-25
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
@@ -11,25 +11,26 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { Text } from '@/components/Text';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { FormatBadges } from '@/components/FormatBadge';
|
||||
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
||||
import { Visualizer } from '@/components/Visualizer';
|
||||
import { QueueTray } from '@/components/queue/QueueTray';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { seekTo, skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
|
||||
|
||||
type IconName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
// Secondary controls are placeholders for now — laid out to settle the design.
|
||||
const SUB_CONTROLS: { icon: IconName; label: string }[] = [
|
||||
{ icon: 'shuffle', label: 'Shuffle' },
|
||||
{ icon: 'heart-outline', label: 'Favorite' },
|
||||
{ icon: 'list-outline', label: 'Queue' },
|
||||
{ icon: 'repeat', label: 'Repeat' },
|
||||
];
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
import {
|
||||
cycleRepeat,
|
||||
seekTo,
|
||||
skipToNext,
|
||||
skipToPrevious,
|
||||
togglePlay,
|
||||
toggleShuffle,
|
||||
} from '@/audio/playbackController';
|
||||
|
||||
const DISMISS_DISTANCE = 140;
|
||||
const DISMISS_VELOCITY = 1000;
|
||||
@@ -128,10 +129,15 @@ export default function NowPlayingScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const [showScopeStage, setShowScopeStage] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const track = usePlayerStore((s) => s.currentTrack);
|
||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const shuffle = usePlayerStore((s) => s.shuffle);
|
||||
const repeat = usePlayerStore((s) => s.repeat);
|
||||
const isFavorite = usePlaylistStore((s) => (track ? s.favoritePaths.has(track.path) : false));
|
||||
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
|
||||
|
||||
const isPlaying = playbackState === 'playing';
|
||||
const isLoading = playbackState === 'loading';
|
||||
@@ -182,7 +188,7 @@ export default function NowPlayingScreen() {
|
||||
}
|
||||
);
|
||||
} else {
|
||||
translateY.value = withSpring(0, { damping: 20, stiffness: 220 });
|
||||
translateY.value = withTiming(0, motion.snap);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -334,20 +340,65 @@ export default function NowPlayingScreen() {
|
||||
|
||||
{layout.showSecondaryControls && (
|
||||
<View style={styles.subRow}>
|
||||
{SUB_CONTROLS.map((c) => (
|
||||
<Pressable
|
||||
key={c.label}
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
accessibilityLabel={c.label}
|
||||
>
|
||||
<Ionicons
|
||||
name={c.icon}
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => void toggleShuffle()}
|
||||
accessibilityLabel="Shuffle"
|
||||
accessibilityState={{ selected: shuffle }}
|
||||
>
|
||||
<Ionicons
|
||||
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}
|
||||
color={colors.textTertiary}
|
||||
color={colors.accent}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
) : (
|
||||
<Ionicons
|
||||
name="repeat"
|
||||
size={SUB_ICON_SIZE}
|
||||
color={repeat === 'all' ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -363,6 +414,7 @@ export default function NowPlayingScreen() {
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
{queueOpen && <QueueTray onClose={() => setQueueOpen(false)} />}
|
||||
</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 { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
import { setupPlayer } from './trackPlayer';
|
||||
import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
|
||||
|
||||
@@ -9,35 +15,113 @@ import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
|
||||
* would slot in behind the same function signatures.
|
||||
*/
|
||||
|
||||
// Unshuffled track-id order for the active context, so shuffle can be toggled
|
||||
// off and the upcoming tail restored to its original sequence (mirrors desktop's
|
||||
// autoQueue + shuffledAutoIndices split, but over RNTP's flat native queue).
|
||||
let originalOrder: string[] | null = null;
|
||||
|
||||
const NEXT_REPEAT: Record<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
|
||||
* than app launch: RNTP starts a foreground MediaSession service on setup, and
|
||||
* Android only permits starting a foreground service while the app is in the
|
||||
* foreground.
|
||||
* foreground. The stored repeat mode is re-applied after a (re)setup so a
|
||||
* deferred init keeps the user's choice.
|
||||
*/
|
||||
async function ensurePlayerReady(): Promise<void> {
|
||||
await setupPlayer();
|
||||
await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat));
|
||||
}
|
||||
|
||||
/** Replace the queue with the given tracks and start playing at startIndex. */
|
||||
export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> {
|
||||
if (tracks.length === 0) return;
|
||||
await ensurePlayerReady();
|
||||
await TrackPlayer.setQueue(tracks.map(toRntpTrack));
|
||||
const queueTracks = tracks.map(toRntpTrack);
|
||||
await TrackPlayer.setQueue(queueTracks);
|
||||
originalOrder = tracks.map((t) => t.id);
|
||||
if (startIndex > 0) {
|
||||
await TrackPlayer.skip(startIndex);
|
||||
}
|
||||
let mirroredQueue = queueTracks;
|
||||
// Honor an already-on shuffle by scrambling the upcoming tail of the new context.
|
||||
if (usePlayerStore.getState().shuffle) {
|
||||
const upcoming = tracks.slice(startIndex + 1);
|
||||
if (upcoming.length > 1) {
|
||||
const shuffledUpcoming = shuffleArray(upcoming).map(toRntpTrack);
|
||||
await TrackPlayer.removeUpcomingTracks();
|
||||
await TrackPlayer.add(shuffledUpcoming);
|
||||
mirroredQueue = [...queueTracks.slice(0, startIndex + 1), ...shuffledUpcoming];
|
||||
}
|
||||
}
|
||||
useQueueStore.getState().setSnapshot(mirroredQueue, startIndex);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
|
||||
/** Fisher–Yates shuffle a copy of the tracks and play from the top. */
|
||||
/** Shuffle a context and play from the top (the library/album "Shuffle" buttons). */
|
||||
export async function shuffleTracks(tracks: Track[]): Promise<void> {
|
||||
const shuffled = [...tracks];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
await playTracks(shuffled);
|
||||
if (tracks.length === 0) return;
|
||||
await ensurePlayerReady();
|
||||
originalOrder = tracks.map((t) => t.id);
|
||||
usePlayerStore.getState().setShuffle(true);
|
||||
const queueTracks = shuffleArray(tracks).map(toRntpTrack);
|
||||
await TrackPlayer.setQueue(queueTracks);
|
||||
useQueueStore.getState().setSnapshot(queueTracks, 0);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
|
||||
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
|
||||
@@ -45,7 +129,13 @@ export async function playSample(): Promise<void> {
|
||||
await ensurePlayerReady();
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
if (queue.length === 0) {
|
||||
await TrackPlayer.add(SAMPLE_TRACKS.map(toRntpTrack));
|
||||
const sampleQueue = SAMPLE_TRACKS.map(toRntpTrack);
|
||||
await TrackPlayer.add(sampleQueue);
|
||||
originalOrder = SAMPLE_TRACKS.map((t) => t.id);
|
||||
useQueueStore.getState().setSnapshot(sampleQueue, 0);
|
||||
} else {
|
||||
const activeIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
useQueueStore.getState().setSnapshot(queue, activeIndex);
|
||||
}
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
@@ -67,6 +157,7 @@ export async function togglePlay(): Promise<void> {
|
||||
export async function skipToNext(): Promise<void> {
|
||||
try {
|
||||
await TrackPlayer.skipToNext();
|
||||
await refreshActiveIndexFromNative();
|
||||
} catch {
|
||||
// no next track — ignore
|
||||
}
|
||||
@@ -75,7 +166,167 @@ export async function skipToNext(): Promise<void> {
|
||||
export async function skipToPrevious(): Promise<void> {
|
||||
try {
|
||||
await TrackPlayer.skipToPrevious();
|
||||
await refreshActiveIndexFromNative();
|
||||
} catch {
|
||||
// no previous track — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Cycle repeat none → all → one (desktop order) and push it to RNTP. */
|
||||
export async function cycleRepeat(): Promise<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 { AstraLogo } from '@/components/AstraLogo';
|
||||
import { FormatBadges } from '@/components/FormatBadge';
|
||||
import { SwipeableRow } from '@/components/SwipeableRow';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { artworkThumbUri } from '@/library/artwork';
|
||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||
import { enqueueEnd, enqueueTop } from '@/audio/playbackController';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
|
||||
const ART_SIZE = 44;
|
||||
@@ -18,6 +21,7 @@ export function TrackRow({
|
||||
onLongPress,
|
||||
showArtist = true,
|
||||
active = false,
|
||||
swipeToQueue = true,
|
||||
}: {
|
||||
track: DbTrack;
|
||||
onPress: () => void;
|
||||
@@ -26,6 +30,8 @@ export function TrackRow({
|
||||
/** Hide on album detail where every row shares the artist. */
|
||||
showArtist?: boolean;
|
||||
active?: boolean;
|
||||
/** Swipe right → play next, swipe left → add to queue. Off in queue-like lists. */
|
||||
swipeToQueue?: boolean;
|
||||
}) {
|
||||
const artworkHash = track.artwork_hash;
|
||||
const [failedArtworkHash, setFailedArtworkHash] = useState<string | null>(null);
|
||||
@@ -33,7 +39,7 @@ export function TrackRow({
|
||||
const thumbUri =
|
||||
artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null;
|
||||
|
||||
return (
|
||||
const row = (
|
||||
<Pressable
|
||||
style={styles.row}
|
||||
onPress={onPress}
|
||||
@@ -92,6 +98,26 @@ export function TrackRow({
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
if (!swipeToQueue) return row;
|
||||
|
||||
return (
|
||||
<SwipeableRow
|
||||
swipeRight={{
|
||||
icon: 'play',
|
||||
color: colors.accent,
|
||||
onCommit: () => void enqueueTop(dbTrackToTrack(track)),
|
||||
}}
|
||||
swipeLeft={{
|
||||
icon: 'list',
|
||||
color: colors.bgTertiary,
|
||||
iconColor: colors.accentText,
|
||||
onCommit: () => void enqueueEnd(dbTrackToTrack(track)),
|
||||
}}
|
||||
>
|
||||
{row}
|
||||
</SwipeableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -101,6 +127,8 @@ const styles = StyleSheet.create({
|
||||
minHeight: ROW_MIN_HEIGHT,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
gap: spacing.md,
|
||||
// Opaque so the swipe action lane only shows where the row has slid away.
|
||||
backgroundColor: colors.bgPrimary,
|
||||
borderBottomColor: colors.glassBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Event, useTrackPlayerEvents, type Track as RntpTrack } from 'react-native-track-player';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
|
||||
export interface QueueSnapshot {
|
||||
tracks: RntpTrack[];
|
||||
activeIndex: number;
|
||||
hasSnapshot: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live view of the JS queue mirror. Opening the tray only falls back to RNTP's
|
||||
* full native queue read when no playback action has populated the mirror yet.
|
||||
*/
|
||||
export function useQueue(active: boolean): QueueSnapshot {
|
||||
const tracks = useQueueStore((s) => s.tracks);
|
||||
const activeIndex = useQueueStore((s) => s.activeIndex);
|
||||
const hasSnapshot = useQueueStore((s) => s.hasSnapshot);
|
||||
const refresh = useQueueStore((s) => s.refreshFromNative);
|
||||
const refreshActiveIndex = useQueueStore((s) => s.refreshActiveIndex);
|
||||
const setActiveIndex = useQueueStore((s) => s.setActiveIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (hasSnapshot) void refreshActiveIndex();
|
||||
else void refresh();
|
||||
}, [active, hasSnapshot, refresh, refreshActiveIndex]);
|
||||
|
||||
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], (event) => {
|
||||
if (!active) return;
|
||||
if (hasSnapshot) setActiveIndex(event.index ?? -1);
|
||||
else void refresh();
|
||||
});
|
||||
|
||||
return { tracks, activeIndex, hasSnapshot, refresh };
|
||||
}
|
||||
@@ -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 type { PlaybackState, Track } from '@/types/audio';
|
||||
|
||||
export type RepeatMode = 'none' | 'one' | 'all';
|
||||
|
||||
/**
|
||||
* Player state — the UI's single source of truth, mirrored from the playback
|
||||
* engine (RNTP at M0) by `usePlaybackSync`. Field names match desktop
|
||||
@@ -14,12 +16,17 @@ interface PlayerStore {
|
||||
duration: number;
|
||||
volume: number; // 0–1
|
||||
isMuted: boolean;
|
||||
// Field names mirror desktop playerStore so queue/transport logic stays consistent.
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
|
||||
setCurrentTrack: (track: Track | null) => void;
|
||||
setPlaybackState: (state: PlaybackState) => void;
|
||||
setProgress: (currentTime: number, duration: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setMuted: (isMuted: boolean) => void;
|
||||
setShuffle: (shuffle: boolean) => void;
|
||||
setRepeat: (repeat: RepeatMode) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -30,12 +37,16 @@ export const usePlayerStore = create<PlayerStore>((set) => ({
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
isMuted: false,
|
||||
shuffle: false,
|
||||
repeat: 'none',
|
||||
|
||||
setCurrentTrack: (currentTrack) => set({ currentTrack }),
|
||||
setPlaybackState: (playbackState) => set({ playbackState }),
|
||||
setProgress: (currentTime, duration) => set({ currentTime, duration }),
|
||||
setVolume: (volume) => set({ volume }),
|
||||
setMuted: (isMuted) => set({ isMuted }),
|
||||
setShuffle: (shuffle) => set({ shuffle }),
|
||||
setRepeat: (repeat) => set({ repeat }),
|
||||
reset: () =>
|
||||
set({ currentTrack: null, playbackState: 'stopped', currentTime: 0, duration: 0 }),
|
||||
}));
|
||||
|
||||
@@ -46,7 +46,8 @@ interface PlaylistStore {
|
||||
addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise<number>;
|
||||
removeFromPlaylist: (id: number, trackPath: string) => 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>;
|
||||
importM3u: () => Promise<M3uImportSummary | 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