From 245c9597c4316d0f1303e758d01d464d879a45c0 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 3 Jan 2026 23:44:15 +0100 Subject: [PATCH] feat: music bar geastures --- bun.lock | 3 + components/music/MiniPlayerBar.tsx | 155 +++++++++++++++++++++++------ package.json | 1 + 3 files changed, 126 insertions(+), 33 deletions(-) diff --git a/bun.lock b/bun.lock index fe41186b..6b714632 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "react-native-device-info": "^15.0.0", "react-native-edge-to-edge": "^1.7.0", "react-native-gesture-handler": "~2.28.0", + "react-native-glass-effect-view": "^1.0.0", "react-native-google-cast": "^4.9.1", "react-native-image-colors": "^2.4.0", "react-native-ios-context-menu": "^3.2.1", @@ -1632,6 +1633,8 @@ "react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="], + "react-native-glass-effect-view": ["react-native-glass-effect-view@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ABYG0oIiqbXsxe2R/cMhNgDn3YgwDLz/2TIN2XOxQopXC+MiGsG9C32VYQvO2sYehcu5JmI3h3EzwLwl6lJhhA=="], + "react-native-google-cast": ["react-native-google-cast@4.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/HvIKAaWHtG6aTNCxrNrqA2ftWGkfH0M/2iN+28pdGUXpKmueb33mgL1m8D4zzwEODQMcmpfoCsym1IwDvugBQ=="], "react-native-image-colors": ["react-native-image-colors@2.5.0", "", { "dependencies": { "node-vibrant": "^4.0.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-3zSDgNj5HaZ0PDWaXkc4BpWpZRM5N4gBsoPC7DBfM/+op69Yvwbc0S1T7CnxBWbvShtOvRE+b2BUBadVn+6z/g=="], diff --git a/components/music/MiniPlayerBar.tsx b/components/music/MiniPlayerBar.tsx index cf96e57d..f7f533c2 100644 --- a/components/music/MiniPlayerBar.tsx +++ b/components/music/MiniPlayerBar.tsx @@ -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 ( - - + - {Platform.OS === "ios" ? ( - - {content} - - ) : ( - {content} - )} - - + + {Platform.OS === "ios" ? ( + + + {content} + + + ) : ( + {content} + )} + + + ); }; @@ -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, diff --git a/package.json b/package.json index 3ed799fb..fa28a72c 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "react-native-device-info": "^15.0.0", "react-native-edge-to-edge": "^1.7.0", "react-native-gesture-handler": "~2.28.0", + "react-native-glass-effect-view": "^1.0.0", "react-native-google-cast": "^4.9.1", "react-native-image-colors": "^2.4.0", "react-native-ios-context-menu": "^3.2.1",