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 {
|
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,
|
||||||
|
|||||||
@@ -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