import { useCallback } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, useAnimatedGestureHandler, runOnJS, interpolate, Extrapolate, Easing, cancelAnimation, } from 'react-native-reanimated'; import { PanGestureHandler } from 'react-native-gesture-handler'; import { BlurView } from 'expo-blur'; import { Image } from 'expo-image'; import * as Haptics from 'expo-haptics'; import Svg, { Path, Rect } from 'react-native-svg'; import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme'; import { usePlayer, useLikes } from '../store'; import { togglePlay } from '../hooks/useAudio'; import { coverUrl } from '../services/api'; import SpectrumBars from './SpectrumBars'; export default function MiniPlayer() { const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer(); const { toggleLike, isLiked } = useLikes(); const track = queue[index]; const translateY = useSharedValue(0); const opacity = useSharedValue(1); const openPlayer = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); setExpanded(true); translateY.value = withSpring(0, { damping: 20, stiffness: 200 }); opacity.value = withSpring(1); }, [setExpanded]); const gestureHandler = useAnimatedGestureHandler({ onStart: (_, ctx) => { cancelAnimation(translateY); ctx.start = translateY.value; }, onActive: (e) => { let dy = e.translationY; if (dy > 0) { dy = dy * 0.15; // rubber band resistance when dragging down } translateY.value = Math.max(dy, -150); opacity.value = interpolate(dy, [-100, 0], [0.5, 1], Extrapolate.CLAMP); }, onEnd: (e) => { const shouldOpen = e.translationY < -40 || e.velocityY < -600; if (shouldOpen) { translateY.value = withTiming(-MINI_PLAYER_HEIGHT, { duration: 200, easing: Easing.out(Easing.cubic), }, () => runOnJS(openPlayer)()); } else { translateY.value = withSpring(0, { damping: 22, stiffness: 250 }); opacity.value = withSpring(1); } }, onFail: () => { translateY.value = withSpring(0, { damping: 22, stiffness: 250 }); opacity.value = withSpring(1); }, onCancel: () => { translateY.value = withSpring(0, { damping: 22, stiffness: 250 }); opacity.value = withSpring(1); }, }); const animStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], opacity: opacity.value, })); const onNext = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); const ni = usePlayer.getState().nextTrack(); usePlayer.getState().setIndex(ni); }, []); if (!track || expanded) return null; const cover = coverUrl(track.cover_url); const pct = duration > 0 ? (progress / duration) * 100 : 0; const liked = isLiked(track.id); return ( {/* Progress bar */} {track.title} {playing && } {track.artist} { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={12} style={s.ctlBtn} > { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={12} style={s.ctlBtn} > {playing ? : } { e.stopPropagation(); onNext(); }} hitSlop={12} style={s.ctlBtn} > ); } const s = StyleSheet.create({ wrap: { position: 'absolute', left: 8, right: 8, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderRadius: 16 }, bgTint: { backgroundColor: 'rgba(18,18,20,0.5)' }, progressBg: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.06)', zIndex: 2 }, progressFill: { height: 2, backgroundColor: Colors.accent }, row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 12, paddingRight: 2, gap: 10 }, cover: { width: 46, height: 46, borderRadius: 10, backgroundColor: Colors.elevated }, info: { flex: 1, minWidth: 0 }, title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, artist: { color: 'rgba(255,255,255,0.5)', fontSize: Font.xs, marginTop: 2 }, controls: { flexDirection: 'row', alignItems: 'center' }, ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, });