swipe miniplayer for next/prev

This commit is contained in:
Boof2015
2026-07-15 14:45:38 -04:00
parent 7703f6c774
commit b290cfed7a
3 changed files with 522 additions and 80 deletions
+422 -80
View File
@@ -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 { import {
View, View,
Pressable, Pressable,
@@ -7,6 +8,14 @@ import {
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; 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 { usePlayerUiStore } from '@/stores/playerUiStore';
import { Text } from './Text'; import { Text } from './Text';
import { AstraLogo } from './AstraLogo'; import { AstraLogo } from './AstraLogo';
@@ -17,24 +26,62 @@ import {
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple'; import { useRipple } from '@/theme/ripple';
import { motion } from '@/theme/motion';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { skipToNext, togglePlay } from '@/audio/playbackController'; import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore'; import { useScopeActive } from '@/scope/scopeStore';
import { artworkThumbFromSource } from '@/library/artwork'; import { artworkThumbFromSource } from '@/library/artwork';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { useAppForeground } from '@/lib/useAppForeground'; import { useAppForeground } from '@/lib/useAppForeground';
import { playHaptic } from '@/lib/haptics';
import { PlaybackTargetPicker } from './PlaybackTargetPicker'; import { PlaybackTargetPicker } from './PlaybackTargetPicker';
import { import {
getDesktopPlaybackPresentation, getDesktopPlaybackPresentation,
getEffectivePlaybackPresentation, getEffectivePlaybackPresentation,
getPhonePlaybackPresentation, getPhonePlaybackPresentation,
type PlaybackPresentation,
} from '@/playback/playbackTargetPresentation'; } from '@/playback/playbackTargetPresentation';
import {
miniPlayerSwipeDistance,
resolveMiniPlayerSwipe,
type MiniPlayerSwipeDirection,
} from './miniPlayerSwipe';
const PILL_HEIGHT = 56; const PILL_HEIGHT = 56;
const ART = 42; const ART = 42;
const CURVE_POINTS = 64; 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({ function MiniProgress({
currentTime, currentTime,
@@ -101,6 +148,249 @@ export function MiniPlayer() {
desktop: desktopPresentation, 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; if (!presentation.visible) return null;
const isDesktop = presentation.target === 'desktop'; const isDesktop = presentation.target === 'desktop';
@@ -129,91 +419,115 @@ export function MiniPlayer() {
} }
void skipToNext(); void skipToNext();
}; };
const onMediaLayout = (e: LayoutChangeEvent) => {
setMediaWidth(e.nativeEvent.layout.width);
};
return ( return (
<> <>
<Pressable <GestureDetector gesture={swipeGesture}>
android_ripple={ripple.bounded} <Animated.View style={styles.pill} onLayout={onLayout}>
style={styles.pill} <Pressable
onPress={() => usePlayerUiStore.getState().openPlayer()} android_ripple={ripple.bounded}
onLayout={onLayout} style={styles.pillPressable}
> onPress={() => usePlayerUiStore.getState().openPlayer()}
{liveScopeActive && pillWidth > 0 && ( >
<View pointerEvents="none" style={styles.spectrum}> {liveScopeActive && pillWidth > 0 && (
<SpectrumCurve <View pointerEvents="none" style={styles.spectrum}>
active={liveScopeActive} <SpectrumCurve
pointCount={CURVE_POINTS} active={liveScopeActive}
dbMin={-84} pointCount={CURVE_POINTS}
dbMax={-20} dbMin={-84}
width={pillWidth} dbMax={-20}
height={PILL_HEIGHT} width={pillWidth}
lineWidth={1.25} height={PILL_HEIGHT}
lineOpacity={0.38} lineWidth={1.25}
fillOpacity={0.3} lineOpacity={0.38}
glow fillOpacity={0.3}
glowOpacity={0.06} glow
/> glowOpacity={0.06}
</View> />
)} </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} />
)} )}
</View> {liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
<View style={styles.meta}> <View style={styles.row}>
<Text variant="body" numberOfLines={1} style={styles.title}> <View style={styles.mediaFrame}>
{presentation.title} <Animated.View
</Text> pointerEvents="none"
<Text variant="label" numberOfLines={1}> style={[styles.swipeCue, styles.previousCue, previousCueStyle]}
{presentation.subtitle} >
</Text> <Ionicons name="play-skip-back" size={20} color={colors.accent} />
</View> </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 ? ( <View style={styles.meta}>
<Pressable <Text variant="body" numberOfLines={1} style={styles.title}>
hitSlop={10} {displayedMedia.title}
android_ripple={ripple.icon(22)} </Text>
onPress={() => setTargetPickerOpen(true)} <Text variant="label" numberOfLines={1}>
style={styles.control} {displayedMedia.subtitle}
accessibilityLabel="Choose output device" </Text>
> </View>
<Ionicons name="desktop-outline" size={21} color={colors.textSecondary} /> </Animated.View>
</Pressable> </View>
) : null}
<Pressable hitSlop={10} android_ripple={ripple.icon(22)} onPress={onTogglePlay} style={styles.control}> {isDesktop ? (
<Ionicons <Pressable
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'} hitSlop={10}
size={24} android_ripple={ripple.icon(22)}
color={colors.accent} 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>
<Pressable hitSlop={10} android_ripple={ripple.icon(22)} onPress={onSkipNext} style={styles.control}> </Animated.View>
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} /> </GestureDetector>
</Pressable>
</View>
{presentation.hasTrack ? (
isDesktop ? (
<MiniProgress
currentTime={presentation.currentTime}
duration={presentation.duration}
isPlaying={isPlaying}
/>
) : (
<PhoneMiniProgress isPlaying={isPlaying} />
)
) : null}
</Pressable>
<PlaybackTargetPicker <PlaybackTargetPicker
visible={targetPickerOpen} visible={targetPickerOpen}
onClose={() => setTargetPickerOpen(false)} onClose={() => setTargetPickerOpen(false)}
@@ -235,6 +549,10 @@ const useStyles = createThemedStyles((colors) => ({
overflow: 'hidden', overflow: 'hidden',
justifyContent: 'center', justifyContent: 'center',
}, },
pillPressable: {
flex: 1,
justifyContent: 'center',
},
spectrum: { spectrum: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@@ -256,6 +574,30 @@ const useStyles = createThemedStyles((colors) => ({
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
gap: 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: { art: {
width: ART, width: ART,
height: ART, height: ART,
+57
View File
@@ -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
);
});
+43
View File
@@ -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';
}