From b290cfed7a02134a4a529b5a7a0b2e8ebcf2018e Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:45:38 -0400 Subject: [PATCH] swipe miniplayer for next/prev --- src/components/MiniPlayer.tsx | 502 ++++++++++++++++++++---- src/components/miniPlayerSwipe.test.mts | 57 +++ src/components/miniPlayerSwipe.ts | 43 ++ 3 files changed, 522 insertions(+), 80 deletions(-) create mode 100644 src/components/miniPlayerSwipe.test.mts create mode 100644 src/components/miniPlayerSwipe.ts diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 35c9dca..75f9588 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -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; +} + +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( + () => ({ + 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(null); + const swipeIdRef = useRef(0); + const incomingDirectionRef = useRef(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 ( <> - usePlayerUiStore.getState().openPlayer()} - onLayout={onLayout} - > - {liveScopeActive && pillWidth > 0 && ( - - - - )} - {liveScopeActive && pillWidth > 0 && } - - - - {presentation.artworkUri ? ( - - ) : ( - + + + usePlayerUiStore.getState().openPlayer()} + > + {liveScopeActive && pillWidth > 0 && ( + + + )} - + {liveScopeActive && pillWidth > 0 && } - - - {presentation.title} - - - {presentation.subtitle} - - + + + + + + + + + + + {displayedMedia.artworkUri ? ( + + ) : ( + + )} + - {isDesktop ? ( - setTargetPickerOpen(true)} - style={styles.control} - accessibilityLabel="Choose output device" - > - - - ) : null} - - + + + {displayedMedia.title} + + + {displayedMedia.subtitle} + + + + + + {isDesktop ? ( + setTargetPickerOpen(true)} + style={styles.control} + accessibilityLabel="Choose output device" + > + + + ) : null} + + + + + + + + + {presentation.hasTrack ? ( + isDesktop ? ( + + ) : ( + + ) + ) : null} - - - - - - {presentation.hasTrack ? ( - isDesktop ? ( - - ) : ( - - ) - ) : null} - + + 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, diff --git a/src/components/miniPlayerSwipe.test.mts b/src/components/miniPlayerSwipe.test.mts new file mode 100644 index 0000000..0157917 --- /dev/null +++ b/src/components/miniPlayerSwipe.test.mts @@ -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 + ); +}); diff --git a/src/components/miniPlayerSwipe.ts b/src/components/miniPlayerSwipe.ts new file mode 100644 index 0000000..2daeff1 --- /dev/null +++ b/src/components/miniPlayerSwipe.ts @@ -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'; +}