import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; import { ActivityIndicator, Platform, StyleSheet, 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 useRouter from "@/hooks/useAppRouter"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; const HORIZONTAL_MARGIN = Platform.OS === "android" ? 12 : 20; 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(); const router = useRouter(); const { currentTrack, isPlaying, isLoading, progress, 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; if (albumId) { return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`; } return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=100&maxWidth=100`; }, [api, currentTrack]); const _progressPercentage = useMemo(() => { if (!duration || duration === 0) return 0; return (progress / duration) * 100; }, [progress, duration]); const handlePress = useCallback(() => { router.push("/(auth)/now-playing"); }, [router]); const handlePlayPause = useCallback( (e: any) => { e.stopPropagation(); togglePlayPause(); }, [togglePlayPause], ); const handleNext = useCallback( (e: any) => { e.stopPropagation(); next(); }, [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) { // Slow return animation - won't jank with navigation translateY.value = withTiming(0, { duration: 600, easing: Easing.out(Easing.cubic), }); runOnJS(handlePress)(); return; } // Swipe down - stop playback and dismiss (check position OR velocity) if (currentPosition > 16 || velocity > VELOCITY_THRESHOLD) { // No need to reset - component will unmount runOnJS(handleDismiss)(); return; } // Only animate back if no action was triggered translateY.value = withTiming(0, { duration: 200, easing: Easing.out(Easing.cubic), }); }); // 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 = ( <> {/* Tappable area: Album art + Track info */} {/* Album art */} {imageUrl ? ( ) : ( )} {/* Track info */} {currentTrack.Name} {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist} {/* Controls */} {isLoading ? ( ) : ( <> )} {/* Progress bar at bottom */} {/* */} ); return ( {Platform.OS === "ios" ? ( {content} ) : ( {content} )} ); }; const styles = StyleSheet.create({ container: { position: "absolute", left: HORIZONTAL_MARGIN, right: HORIZONTAL_MARGIN, shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 8, }, touchable: { borderRadius: 50, overflow: "hidden", }, blurContainer: { flex: 1, }, androidContainer: { flex: 1, flexDirection: "row", alignItems: "center", paddingHorizontal: 10, paddingVertical: 8, backgroundColor: "rgba(28, 28, 30, 0.97)", borderRadius: 14, borderWidth: 0.5, borderColor: "rgba(255, 255, 255, 0.1)", }, tappableArea: { flex: 1, flexDirection: "row", alignItems: "center", }, albumArt: { width: 32, height: 32, borderRadius: 8, overflow: "hidden", backgroundColor: "#333", }, albumImage: { width: "100%", height: "100%", }, albumPlaceholder: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#2a2a2a", }, trackInfo: { flex: 1, marginLeft: 12, marginRight: 8, justifyContent: "center", }, trackTitle: { color: "white", fontSize: 14, fontWeight: "600", }, artistName: { color: "rgba(255, 255, 255, 0.6)", fontSize: 12, }, controls: { flexDirection: "row", alignItems: "center", }, controlButton: { padding: 8, }, loader: { marginHorizontal: 16, }, progressContainer: { position: "absolute", bottom: 0, left: 10, right: 10, height: 3, backgroundColor: "rgba(255, 255, 255, 0.15)", borderRadius: 1.5, }, progressFill: { height: "100%", backgroundColor: "white", borderRadius: 1.5, }, });