mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: music bar geastures
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -11,6 +10,17 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import Animated, {
|
||||
Easing,
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -20,6 +30,18 @@ const HORIZONTAL_MARGIN = Platform.OS === "android" ? 8 : 16;
|
||||
const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52;
|
||||
const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50;
|
||||
|
||||
// Gesture thresholds
|
||||
const VELOCITY_THRESHOLD = 1000;
|
||||
|
||||
// Logarithmic slowdown - never stops, just gets progressively slower
|
||||
const rubberBand = (distance: number, scale: number = 8): number => {
|
||||
"worklet";
|
||||
const absDistance = Math.abs(distance);
|
||||
const sign = distance < 0 ? -1 : 1;
|
||||
// Logarithmic: keeps growing but slower and slower
|
||||
return sign * scale * Math.log(1 + absDistance / scale);
|
||||
};
|
||||
|
||||
export const MiniPlayerBar: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -32,8 +54,12 @@ export const MiniPlayerBar: React.FC = () => {
|
||||
duration,
|
||||
togglePlayPause,
|
||||
next,
|
||||
stop,
|
||||
} = useMusicPlayer();
|
||||
|
||||
// Gesture state
|
||||
const translateY = useSharedValue(0);
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!api || !currentTrack) return null;
|
||||
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||
@@ -68,6 +94,66 @@ export const MiniPlayerBar: React.FC = () => {
|
||||
[next],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
// Pan gesture for swipe up (open modal) and swipe down (dismiss)
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetY([-15, 15])
|
||||
.onUpdate((event) => {
|
||||
// Logarithmic slowdown - keeps moving but progressively slower
|
||||
translateY.value = rubberBand(event.translationY, 6);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
const velocity = event.velocityY;
|
||||
const currentPosition = translateY.value;
|
||||
|
||||
// Swipe up - open modal (check position OR velocity)
|
||||
if (currentPosition < -16 || velocity < -VELOCITY_THRESHOLD) {
|
||||
runOnJS(handlePress)();
|
||||
}
|
||||
// Swipe down - stop playback and dismiss (check position OR velocity)
|
||||
else if (currentPosition > 16 || velocity > VELOCITY_THRESHOLD) {
|
||||
runOnJS(handleDismiss)();
|
||||
}
|
||||
|
||||
// Smooth return to original position (no bounce)
|
||||
translateY.value = withTiming(0, {
|
||||
duration: 200,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
});
|
||||
|
||||
// Tap gesture for opening modal (preserves existing behavior)
|
||||
const tapGesture = Gesture.Tap().onEnd(() => {
|
||||
runOnJS(handlePress)();
|
||||
});
|
||||
|
||||
// Combine gestures - pan takes priority over tap
|
||||
const composedGesture = Gesture.Race(panGesture, tapGesture);
|
||||
|
||||
// Animated styles for the container
|
||||
const animatedContainerStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
// Animated styles for the inner bar
|
||||
const animatedBarStyle = useAnimatedStyle(() => ({
|
||||
height: interpolate(
|
||||
translateY.value,
|
||||
[-50, 0, 50],
|
||||
[BAR_HEIGHT + 12, BAR_HEIGHT, BAR_HEIGHT],
|
||||
Extrapolation.EXTEND,
|
||||
),
|
||||
opacity: interpolate(
|
||||
translateY.value,
|
||||
[0, 30],
|
||||
[1, 0.6],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}));
|
||||
|
||||
if (!currentTrack) return null;
|
||||
|
||||
const content = (
|
||||
@@ -136,31 +222,40 @@ export const MiniPlayerBar: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
bottom:
|
||||
BOTTOM_TAB_HEIGHT +
|
||||
insets.bottom +
|
||||
(Platform.OS === "android" ? 32 : 4),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.9}
|
||||
style={styles.touchable}
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
bottom:
|
||||
BOTTOM_TAB_HEIGHT +
|
||||
insets.bottom +
|
||||
(Platform.OS === "android" ? 32 : 4),
|
||||
},
|
||||
animatedContainerStyle,
|
||||
]}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
{content}
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={styles.androidContainer}>{content}</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Animated.View style={[styles.touchable, animatedBarStyle]}>
|
||||
{Platform.OS === "ios" ? (
|
||||
<GlassEffectView style={styles.blurContainer}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingRight: 10,
|
||||
paddingLeft: 20,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</View>
|
||||
</GlassEffectView>
|
||||
) : (
|
||||
<View style={styles.androidContainer}>{content}</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -180,20 +275,14 @@ const styles = StyleSheet.create({
|
||||
overflow: "hidden",
|
||||
},
|
||||
blurContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingRight: 10,
|
||||
paddingLeft: 20,
|
||||
paddingVertical: 0,
|
||||
height: BAR_HEIGHT,
|
||||
backgroundColor: "rgba(40, 40, 40, 0.5)",
|
||||
flex: 1,
|
||||
},
|
||||
androidContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
height: BAR_HEIGHT,
|
||||
backgroundColor: "rgba(28, 28, 30, 0.97)",
|
||||
borderRadius: 14,
|
||||
borderWidth: 0.5,
|
||||
|
||||
Reference in New Issue
Block a user