mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
swipe miniplayer for next/prev
This commit is contained in:
+422
-80
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
/* eslint-disable react-hooks/immutability, react-hooks/refs -- Reanimated gesture state and async transition refs are intentionally mutable. */
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
@@ -7,6 +8,14 @@ import {
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
||||
import { Text } from './Text';
|
||||
import { AstraLogo } from './AstraLogo';
|
||||
@@ -17,24 +26,62 @@ import {
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||
import { skipToNext, togglePlay } from '@/audio/playbackController';
|
||||
import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { artworkThumbFromSource } from '@/library/artwork';
|
||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
||||
import { useAppForeground } from '@/lib/useAppForeground';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
|
||||
import {
|
||||
getDesktopPlaybackPresentation,
|
||||
getEffectivePlaybackPresentation,
|
||||
getPhonePlaybackPresentation,
|
||||
type PlaybackPresentation,
|
||||
} from '@/playback/playbackTargetPresentation';
|
||||
import {
|
||||
miniPlayerSwipeDistance,
|
||||
resolveMiniPlayerSwipe,
|
||||
type MiniPlayerSwipeDirection,
|
||||
} from './miniPlayerSwipe';
|
||||
|
||||
const PILL_HEIGHT = 56;
|
||||
const ART = 42;
|
||||
const CURVE_POINTS = 64;
|
||||
const SWIPE_ACTIVE_OFFSET_X = 10;
|
||||
const SWIPE_FAIL_OFFSET_Y = 20;
|
||||
const SWIPE_RESPONSE_TIMEOUT_MS = 1500;
|
||||
const COMMITTED_MEDIA_OPACITY = 0.18;
|
||||
|
||||
interface MiniPlayerMediaPresentation {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
artworkUri: string | null;
|
||||
}
|
||||
|
||||
interface PendingMiniPlayerSwipe {
|
||||
id: number;
|
||||
direction: MiniPlayerSwipeDirection;
|
||||
target: PlaybackPresentation['target'];
|
||||
trackKey: string | null;
|
||||
mediaWidth: number;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
function sameMiniPlayerMedia(
|
||||
left: MiniPlayerMediaPresentation,
|
||||
right: MiniPlayerMediaPresentation
|
||||
): boolean {
|
||||
return left.key === right.key &&
|
||||
left.title === right.title &&
|
||||
left.subtitle === right.subtitle &&
|
||||
left.artworkUri === right.artworkUri;
|
||||
}
|
||||
|
||||
function MiniProgress({
|
||||
currentTime,
|
||||
@@ -101,6 +148,249 @@ export function MiniPlayer() {
|
||||
desktop: desktopPresentation,
|
||||
});
|
||||
|
||||
const liveMediaKey = `${presentation.target}:${presentation.trackKey ?? 'none'}`;
|
||||
const liveMedia = useMemo<MiniPlayerMediaPresentation>(
|
||||
() => ({
|
||||
key: liveMediaKey,
|
||||
title: presentation.title,
|
||||
subtitle: presentation.subtitle,
|
||||
artworkUri: presentation.artworkUri,
|
||||
}),
|
||||
[
|
||||
liveMediaKey,
|
||||
presentation.artworkUri,
|
||||
presentation.subtitle,
|
||||
presentation.title,
|
||||
]
|
||||
);
|
||||
const [displayedMedia, setDisplayedMedia] = useState(liveMedia);
|
||||
const [mediaWidth, setMediaWidth] = useState(0);
|
||||
const [transitionPending, setTransitionPending] = useState(false);
|
||||
const mediaTranslateX = useSharedValue(0);
|
||||
const mediaOpacity = useSharedValue(1);
|
||||
const cueSide = useSharedValue(0);
|
||||
const cueOpacity = useSharedValue(0);
|
||||
const armed = useSharedValue(false);
|
||||
const transitionOnUi = useSharedValue(false);
|
||||
const pendingSwipeRef = useRef<PendingMiniPlayerSwipe | null>(null);
|
||||
const swipeIdRef = useRef(0);
|
||||
const incomingDirectionRef = useRef<MiniPlayerSwipeDirection | null>(null);
|
||||
|
||||
const rejectPendingSwipe = useCallback((id: number, haptic = true) => {
|
||||
const pending = pendingSwipeRef.current;
|
||||
if (!pending || pending.id !== id) return;
|
||||
clearTimeout(pending.timeout);
|
||||
pendingSwipeRef.current = null;
|
||||
incomingDirectionRef.current = null;
|
||||
transitionOnUi.value = false;
|
||||
armed.value = false;
|
||||
setTransitionPending(false);
|
||||
mediaTranslateX.value = withTiming(0, motion.quick);
|
||||
mediaOpacity.value = withTiming(1, motion.quick);
|
||||
cueOpacity.value = withTiming(0, motion.quick);
|
||||
if (haptic) playHaptic('reject');
|
||||
}, [armed, cueOpacity, mediaOpacity, mediaTranslateX, transitionOnUi]);
|
||||
|
||||
const completePendingSwipe = useCallback((
|
||||
id: number,
|
||||
nextMedia: MiniPlayerMediaPresentation
|
||||
) => {
|
||||
const pending = pendingSwipeRef.current;
|
||||
if (!pending || pending.id !== id) return;
|
||||
clearTimeout(pending.timeout);
|
||||
pendingSwipeRef.current = null;
|
||||
transitionOnUi.value = false;
|
||||
armed.value = false;
|
||||
setTransitionPending(false);
|
||||
incomingDirectionRef.current = pending.direction;
|
||||
mediaTranslateX.value = pending.direction === 'next'
|
||||
? pending.mediaWidth / 2
|
||||
: -pending.mediaWidth / 2;
|
||||
mediaOpacity.value = COMMITTED_MEDIA_OPACITY;
|
||||
cueOpacity.value = withTiming(0, motion.quick);
|
||||
setDisplayedMedia(nextMedia);
|
||||
playHaptic('confirm');
|
||||
}, [armed, cueOpacity, mediaOpacity, mediaTranslateX, transitionOnUi]);
|
||||
|
||||
const dispatchPendingSwipe = useCallback((id: number) => {
|
||||
const pending = pendingSwipeRef.current;
|
||||
if (!pending || pending.id !== id) return;
|
||||
const command = pending.target === 'desktop'
|
||||
? sendDesktopControl(pending.direction === 'next' ? 'next' : 'previous')
|
||||
: pending.direction === 'next'
|
||||
? skipToNext()
|
||||
: skipToPrevious();
|
||||
void command.catch(() => rejectPendingSwipe(id));
|
||||
}, [rejectPendingSwipe, sendDesktopControl]);
|
||||
|
||||
const beginPendingSwipe = useCallback((direction: MiniPlayerSwipeDirection) => {
|
||||
if (pendingSwipeRef.current || mediaWidth <= 0 || !presentation.hasTrack) {
|
||||
transitionOnUi.value = false;
|
||||
armed.value = false;
|
||||
mediaTranslateX.value = withTiming(0, motion.quick);
|
||||
mediaOpacity.value = withTiming(1, motion.quick);
|
||||
cueOpacity.value = withTiming(0, motion.quick);
|
||||
playHaptic('reject');
|
||||
return;
|
||||
}
|
||||
const id = ++swipeIdRef.current;
|
||||
const timeout = setTimeout(
|
||||
() => rejectPendingSwipe(id),
|
||||
motion.quick.duration + SWIPE_RESPONSE_TIMEOUT_MS
|
||||
);
|
||||
pendingSwipeRef.current = {
|
||||
id,
|
||||
direction,
|
||||
target: presentation.target,
|
||||
trackKey: presentation.trackKey,
|
||||
mediaWidth,
|
||||
timeout,
|
||||
};
|
||||
transitionOnUi.value = true;
|
||||
setTransitionPending(true);
|
||||
setDisplayedMedia((current) => sameMiniPlayerMedia(current, liveMedia) ? current : liveMedia);
|
||||
const exitX = direction === 'next' ? -mediaWidth / 2 : mediaWidth / 2;
|
||||
cueSide.value = direction === 'next' ? -1 : 1;
|
||||
cueOpacity.value = withTiming(1, motion.quick);
|
||||
mediaOpacity.value = withTiming(COMMITTED_MEDIA_OPACITY, motion.quick);
|
||||
mediaTranslateX.value = withTiming(exitX, motion.quick, (finished) => {
|
||||
if (finished) runOnJS(dispatchPendingSwipe)(id);
|
||||
else runOnJS(rejectPendingSwipe)(id);
|
||||
});
|
||||
}, [
|
||||
armed,
|
||||
cueOpacity,
|
||||
cueSide,
|
||||
dispatchPendingSwipe,
|
||||
liveMedia,
|
||||
mediaOpacity,
|
||||
mediaTranslateX,
|
||||
mediaWidth,
|
||||
presentation.hasTrack,
|
||||
presentation.target,
|
||||
presentation.trackKey,
|
||||
rejectPendingSwipe,
|
||||
transitionOnUi,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingSwipeRef.current) return;
|
||||
setDisplayedMedia((current) => sameMiniPlayerMedia(current, liveMedia) ? current : liveMedia);
|
||||
}, [liveMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = pendingSwipeRef.current;
|
||||
if (!pending) return;
|
||||
if (presentation.target !== pending.target) {
|
||||
rejectPendingSwipe(pending.id, false);
|
||||
return;
|
||||
}
|
||||
if (!presentation.visible || !presentation.hasTrack) {
|
||||
rejectPendingSwipe(pending.id);
|
||||
return;
|
||||
}
|
||||
if (presentation.trackKey !== pending.trackKey) {
|
||||
completePendingSwipe(pending.id, liveMedia);
|
||||
}
|
||||
}, [
|
||||
completePendingSwipe,
|
||||
liveMedia,
|
||||
presentation.hasTrack,
|
||||
presentation.target,
|
||||
presentation.trackKey,
|
||||
presentation.visible,
|
||||
rejectPendingSwipe,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!incomingDirectionRef.current) return;
|
||||
incomingDirectionRef.current = null;
|
||||
mediaTranslateX.value = withTiming(0, motion.snap);
|
||||
mediaOpacity.value = withTiming(1, motion.snap);
|
||||
}, [displayedMedia.key, mediaOpacity, mediaTranslateX]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const pending = pendingSwipeRef.current;
|
||||
if (pending) clearTimeout(pending.timeout);
|
||||
pendingSwipeRef.current = null;
|
||||
cancelAnimation(mediaTranslateX);
|
||||
cancelAnimation(mediaOpacity);
|
||||
cancelAnimation(cueOpacity);
|
||||
}, [cueOpacity, mediaOpacity, mediaTranslateX]);
|
||||
|
||||
const mediaStyle = useAnimatedStyle(() => ({
|
||||
opacity: mediaOpacity.value,
|
||||
transform: [{ translateX: mediaTranslateX.value }],
|
||||
}));
|
||||
const previousCueStyle = useAnimatedStyle(() => ({
|
||||
opacity: cueSide.value > 0 ? cueOpacity.value : 0,
|
||||
transform: [{ scale: 0.85 + cueOpacity.value * 0.15 }],
|
||||
}));
|
||||
const nextCueStyle = useAnimatedStyle(() => ({
|
||||
opacity: cueSide.value < 0 ? cueOpacity.value : 0,
|
||||
transform: [{ scale: 0.85 + cueOpacity.value * 0.15 }],
|
||||
}));
|
||||
const swipeGesture = useMemo(
|
||||
() => Gesture.Pan()
|
||||
.enabled(presentation.hasTrack && mediaWidth > 0 && !transitionPending)
|
||||
.activeOffsetX([-SWIPE_ACTIVE_OFFSET_X, SWIPE_ACTIVE_OFFSET_X])
|
||||
.failOffsetY([-SWIPE_FAIL_OFFSET_Y, SWIPE_FAIL_OFFSET_Y])
|
||||
.onBegin(() => {
|
||||
cancelAnimation(mediaTranslateX);
|
||||
cancelAnimation(mediaOpacity);
|
||||
cancelAnimation(cueOpacity);
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const maxTranslation = mediaWidth / 2;
|
||||
const translation = Math.max(
|
||||
-maxTranslation,
|
||||
Math.min(maxTranslation, event.translationX)
|
||||
);
|
||||
const threshold = Math.max(1, miniPlayerSwipeDistance(mediaWidth));
|
||||
const progress = Math.min(1, Math.abs(translation) / threshold);
|
||||
mediaTranslateX.value = translation;
|
||||
mediaOpacity.value = 1 - progress * 0.15;
|
||||
cueSide.value = translation > 0 ? 1 : translation < 0 ? -1 : 0;
|
||||
cueOpacity.value = progress;
|
||||
const nowArmed = Math.abs(translation) >= threshold;
|
||||
if (nowArmed !== armed.value) {
|
||||
armed.value = nowArmed;
|
||||
runOnJS(playHaptic)(nowArmed ? 'threshold' : 'thresholdExit');
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
const direction = resolveMiniPlayerSwipe({
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
mediaWidth,
|
||||
});
|
||||
if (!direction) return;
|
||||
armed.value = false;
|
||||
transitionOnUi.value = true;
|
||||
runOnJS(beginPendingSwipe)(direction);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
if (transitionOnUi.value) return;
|
||||
if (armed.value) runOnJS(playHaptic)('thresholdExit');
|
||||
armed.value = false;
|
||||
mediaTranslateX.value = withTiming(0, motion.quick);
|
||||
mediaOpacity.value = withTiming(1, motion.quick);
|
||||
cueOpacity.value = withTiming(0, motion.quick);
|
||||
}),
|
||||
[
|
||||
armed,
|
||||
beginPendingSwipe,
|
||||
cueOpacity,
|
||||
cueSide,
|
||||
mediaOpacity,
|
||||
mediaTranslateX,
|
||||
mediaWidth,
|
||||
presentation.hasTrack,
|
||||
transitionOnUi,
|
||||
transitionPending,
|
||||
]
|
||||
);
|
||||
|
||||
if (!presentation.visible) return null;
|
||||
|
||||
const isDesktop = presentation.target === 'desktop';
|
||||
@@ -129,91 +419,115 @@ export function MiniPlayer() {
|
||||
}
|
||||
void skipToNext();
|
||||
};
|
||||
const onMediaLayout = (e: LayoutChangeEvent) => {
|
||||
setMediaWidth(e.nativeEvent.layout.width);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.pill}
|
||||
onPress={() => usePlayerUiStore.getState().openPlayer()}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{liveScopeActive && pillWidth > 0 && (
|
||||
<View pointerEvents="none" style={styles.spectrum}>
|
||||
<SpectrumCurve
|
||||
active={liveScopeActive}
|
||||
pointCount={CURVE_POINTS}
|
||||
dbMin={-84}
|
||||
dbMax={-20}
|
||||
width={pillWidth}
|
||||
height={PILL_HEIGHT}
|
||||
lineWidth={1.25}
|
||||
lineOpacity={0.38}
|
||||
fillOpacity={0.3}
|
||||
glow
|
||||
glowOpacity={0.06}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
|
||||
|
||||
<View style={styles.row}>
|
||||
<View style={styles.art}>
|
||||
{presentation.artworkUri ? (
|
||||
<Image
|
||||
source={{ uri: artworkThumbFromSource(presentation.artworkUri) ?? presentation.artworkUri }}
|
||||
style={styles.artImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<AstraLogo size={20} />
|
||||
<GestureDetector gesture={swipeGesture}>
|
||||
<Animated.View style={styles.pill} onLayout={onLayout}>
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.pillPressable}
|
||||
onPress={() => usePlayerUiStore.getState().openPlayer()}
|
||||
>
|
||||
{liveScopeActive && pillWidth > 0 && (
|
||||
<View pointerEvents="none" style={styles.spectrum}>
|
||||
<SpectrumCurve
|
||||
active={liveScopeActive}
|
||||
pointCount={CURVE_POINTS}
|
||||
dbMin={-84}
|
||||
dbMax={-20}
|
||||
width={pillWidth}
|
||||
height={PILL_HEIGHT}
|
||||
lineWidth={1.25}
|
||||
lineOpacity={0.38}
|
||||
fillOpacity={0.3}
|
||||
glow
|
||||
glowOpacity={0.06}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
|
||||
|
||||
<View style={styles.meta}>
|
||||
<Text variant="body" numberOfLines={1} style={styles.title}>
|
||||
{presentation.title}
|
||||
</Text>
|
||||
<Text variant="label" numberOfLines={1}>
|
||||
{presentation.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.mediaFrame}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[styles.swipeCue, styles.previousCue, previousCueStyle]}
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={20} color={colors.accent} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[styles.swipeCue, styles.nextCue, nextCueStyle]}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={20} color={colors.accent} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.media, mediaStyle]} onLayout={onMediaLayout}>
|
||||
<View style={styles.art}>
|
||||
{displayedMedia.artworkUri ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: artworkThumbFromSource(displayedMedia.artworkUri) ?? displayedMedia.artworkUri,
|
||||
}}
|
||||
style={styles.artImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<AstraLogo size={20} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isDesktop ? (
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
android_ripple={ripple.icon(22)}
|
||||
onPress={() => setTargetPickerOpen(true)}
|
||||
style={styles.control}
|
||||
accessibilityLabel="Choose output device"
|
||||
>
|
||||
<Ionicons name="desktop-outline" size={21} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable hitSlop={10} android_ripple={ripple.icon(22)} onPress={onTogglePlay} style={styles.control}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={24}
|
||||
color={colors.accent}
|
||||
/>
|
||||
<View style={styles.meta}>
|
||||
<Text variant="body" numberOfLines={1} style={styles.title}>
|
||||
{displayedMedia.title}
|
||||
</Text>
|
||||
<Text variant="label" numberOfLines={1}>
|
||||
{displayedMedia.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{isDesktop ? (
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
android_ripple={ripple.icon(22)}
|
||||
onPress={() => setTargetPickerOpen(true)}
|
||||
style={styles.control}
|
||||
accessibilityLabel="Choose output device"
|
||||
>
|
||||
<Ionicons name="desktop-outline" size={21} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable hitSlop={10} android_ripple={ripple.icon(22)} onPress={onTogglePlay} style={styles.control}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={24}
|
||||
color={colors.accent}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable hitSlop={10} android_ripple={ripple.icon(22)} onPress={onSkipNext} style={styles.control}>
|
||||
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{presentation.hasTrack ? (
|
||||
isDesktop ? (
|
||||
<MiniProgress
|
||||
currentTime={presentation.currentTime}
|
||||
duration={presentation.duration}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
) : (
|
||||
<PhoneMiniProgress isPlaying={isPlaying} />
|
||||
)
|
||||
) : null}
|
||||
</Pressable>
|
||||
<Pressable hitSlop={10} android_ripple={ripple.icon(22)} onPress={onSkipNext} style={styles.control}>
|
||||
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{presentation.hasTrack ? (
|
||||
isDesktop ? (
|
||||
<MiniProgress
|
||||
currentTime={presentation.currentTime}
|
||||
duration={presentation.duration}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
) : (
|
||||
<PhoneMiniProgress isPlaying={isPlaying} />
|
||||
)
|
||||
) : null}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<PlaybackTargetPicker
|
||||
visible={targetPickerOpen}
|
||||
onClose={() => setTargetPickerOpen(false)}
|
||||
@@ -235,6 +549,10 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
pillPressable: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
spectrum: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -256,6 +574,30 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
mediaFrame: {
|
||||
flex: 1,
|
||||
height: ART,
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
media: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
swipeCue: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
previousCue: {
|
||||
left: spacing.sm,
|
||||
},
|
||||
nextCue: {
|
||||
right: spacing.sm,
|
||||
},
|
||||
art: {
|
||||
width: ART,
|
||||
height: ART,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
miniPlayerSwipeDistance,
|
||||
resolveMiniPlayerSwipe,
|
||||
} from './miniPlayerSwipe.ts';
|
||||
|
||||
const MEDIA_WIDTH = 200;
|
||||
|
||||
test('maps committed left and right drags to next and previous', () => {
|
||||
const threshold = miniPlayerSwipeDistance(MEDIA_WIDTH);
|
||||
|
||||
assert.equal(
|
||||
resolveMiniPlayerSwipe({ translationX: -threshold, velocityX: 0, mediaWidth: MEDIA_WIDTH }),
|
||||
'next'
|
||||
);
|
||||
assert.equal(
|
||||
resolveMiniPlayerSwipe({ translationX: threshold, velocityX: 0, mediaWidth: MEDIA_WIDTH }),
|
||||
'previous'
|
||||
);
|
||||
});
|
||||
|
||||
test('commits a short same-direction flick', () => {
|
||||
assert.equal(
|
||||
resolveMiniPlayerSwipe({ translationX: -20, velocityX: -720, mediaWidth: MEDIA_WIDTH }),
|
||||
'next'
|
||||
);
|
||||
assert.equal(
|
||||
resolveMiniPlayerSwipe({ translationX: 20, velocityX: 720, mediaWidth: MEDIA_WIDTH }),
|
||||
'previous'
|
||||
);
|
||||
});
|
||||
|
||||
test('cancels a short slow swipe', () => {
|
||||
assert.equal(
|
||||
resolveMiniPlayerSwipe({ translationX: -20, velocityX: -300, mediaWidth: MEDIA_WIDTH }),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects flick velocity that opposes the drag', () => {
|
||||
assert.equal(
|
||||
resolveMiniPlayerSwipe({ translationX: -20, velocityX: 900, mediaWidth: MEDIA_WIDTH }),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
resolveMiniPlayerSwipe({ translationX: 20, velocityX: -900, mediaWidth: MEDIA_WIDTH }),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('requires a measured media width', () => {
|
||||
assert.equal(
|
||||
resolveMiniPlayerSwipe({ translationX: -80, velocityX: -900, mediaWidth: 0 }),
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
export type MiniPlayerSwipeDirection = 'previous' | 'next';
|
||||
|
||||
export interface MiniPlayerSwipeSample {
|
||||
translationX: number;
|
||||
velocityX: number;
|
||||
mediaWidth: number;
|
||||
}
|
||||
|
||||
export const MINI_PLAYER_SWIPE_DISTANCE_FRACTION = 0.24;
|
||||
export const MINI_PLAYER_SWIPE_FLICK_VELOCITY = 700;
|
||||
export const MINI_PLAYER_SWIPE_FLICK_MIN_DISTANCE = 16;
|
||||
|
||||
export function miniPlayerSwipeDistance(mediaWidth: number): number {
|
||||
'worklet';
|
||||
return Math.max(0, mediaWidth) * MINI_PLAYER_SWIPE_DISTANCE_FRACTION;
|
||||
}
|
||||
|
||||
/** Resolve a released horizontal drag into a transport command, if it committed. */
|
||||
export function resolveMiniPlayerSwipe({
|
||||
translationX,
|
||||
velocityX,
|
||||
mediaWidth,
|
||||
}: MiniPlayerSwipeSample): MiniPlayerSwipeDirection | null {
|
||||
'worklet';
|
||||
if (
|
||||
!Number.isFinite(translationX) ||
|
||||
!Number.isFinite(velocityX) ||
|
||||
!Number.isFinite(mediaWidth) ||
|
||||
mediaWidth <= 0 ||
|
||||
translationX === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const distanceCommit = Math.abs(translationX) >= miniPlayerSwipeDistance(mediaWidth);
|
||||
const sameDirectionFlick =
|
||||
Math.abs(translationX) >= MINI_PLAYER_SWIPE_FLICK_MIN_DISTANCE &&
|
||||
Math.abs(velocityX) >= MINI_PLAYER_SWIPE_FLICK_VELOCITY &&
|
||||
Math.sign(translationX) === Math.sign(velocityX);
|
||||
|
||||
if (!distanceCommit && !sameDirectionFlick) return null;
|
||||
return translationX < 0 ? 'next' : 'previous';
|
||||
}
|
||||
Reference in New Issue
Block a user