feat: music bar geastures

This commit is contained in:
Fredrik Burmester
2026-01-03 23:44:15 +01:00
parent 966a8e8f24
commit 245c9597c4
3 changed files with 126 additions and 33 deletions

View File

@@ -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=="],

View File

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

View File

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