import { useCallback, useEffect, useRef, useState } from 'react'; import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView, Platform, StatusBar, Alert, } 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 { Image } from 'expo-image'; import Slider from '@react-native-community/slider'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BlurView } from 'expo-blur'; import * as Haptics from 'expo-haptics'; import * as Clipboard from 'expo-clipboard'; import Svg, { Path, Rect, Polyline, Circle } from 'react-native-svg'; import { Colors, Font, Radius } from '../constants/theme'; import { usePlayer, useLikes } from '../store'; import { togglePlay, seekTo } from '../hooks/useAudio'; import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api'; import { useDynamicColor, useNextTrackPreload } from '../hooks/usePlayerFeatures'; import { useShakeToShuffle } from '../hooks/useShakeToShuffle'; import ShareTrackModal from './ShareTrackModal'; const { width, height } = Dimensions.get('window'); const COVER_SIZE = width - 56; const DISMISS_THRESHOLD = 120; const DISMISS_VELOCITY = 800; export default function Player() { const { queue, index, playing, progress, duration, repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle, } = usePlayer(); const { toggleLike, isLiked } = useLikes(); const insets = useSafeAreaInsets(); const track = queue[index]; const [lyricsMode, setLyricsMode] = useState(false); const [lrcLines, setLrcLines] = useState([]); const [plainLyrics, setPlain] = useState(null); const [lyricsLoading, setLyricsLoading] = useState(false); const [isSeeking, setIsSeeking] = useState(false); const [shareOpen, setShareOpen] = useState(false); const lyricsScrollRef = useRef(null); const lineHeights = useRef([]); const panRef = useRef(null); const coverPanRef = useRef(null); // Shared values const translateY = useSharedValue(0); const coverScale = useSharedValue(playing ? 1 : 0.875); const coverTranslateX = useSharedValue(0); const lyricsOpacity = useSharedValue(0); const playerOpacity = useSharedValue(1); // Dynamic color from cover const cover = track ? coverUrl(track.cover_url) : null; const dynColor = useDynamicColor(cover); // Shake → shuffle const onShake = useCallback(() => { try { if (!shuffle) toggleShuffle(); const state = usePlayer.getState(); if (state.nextTrack && state.setIndex) { state.setIndex(state.nextTrack()); } } catch {} }, [shuffle, toggleShuffle]); useShakeToShuffle(onShake); // Preload next track 30s before end useNextTrackPreload(); // Cover scale on play/pause useEffect(() => { coverScale.value = withSpring(playing ? 1 : 0.875, { damping: 18, mass: 1, stiffness: 120, }); }, [playing]); // Lyrics mode transition useEffect(() => { if (lyricsMode) { lyricsOpacity.value = withTiming(1, { duration: 350, easing: Easing.out(Easing.cubic) }); playerOpacity.value = withTiming(0, { duration: 250 }); } else { lyricsOpacity.value = withTiming(0, { duration: 250 }); playerOpacity.value = withTiming(1, { duration: 350, easing: Easing.out(Easing.cubic) }); } }, [lyricsMode]); // Reset on track change useEffect(() => { setLrcLines([]); setPlain(null); setLyricsMode(false); translateY.value = 0; coverScale.value = withSpring(1, { damping: 18, stiffness: 120 }); }, [track?.id]); // Auto-scroll lyrics const currentLineIdx = lrcLines.length > 0 ? lrcLines.reduce((acc, line, i) => line.time <= progress ? i : acc, 0) : -1; useEffect(() => { if (!lyricsMode || currentLineIdx < 1) return; const y = lineHeights.current.slice(0, currentLineIdx - 1).reduce((s, h) => s + h, 0); lyricsScrollRef.current?.scrollTo({ y, animated: true }); }, [currentLineIdx, lyricsMode]); const loadLyrics = useCallback(async () => { if (!track) return; if (lrcLines.length > 0 || plainLyrics) { setLyricsMode(true); return; } setLyricsLoading(true); try { const data = await fetchLyrics(track.id); if (data?.synced) setLrcLines(parseLrc(data.synced)); else if (data?.plain) setPlain(data.plain); } catch {} setLyricsLoading(false); setLyricsMode(true); }, [track, lrcLines.length, plainLyrics]); const close = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setExpanded(false); translateY.value = 0; }, [setExpanded]); // Gesture handler - fixed to prevent crashes const gestureHandler = useAnimatedGestureHandler({ onStart: (_, ctx) => { cancelAnimation(translateY); ctx.start = translateY.value; }, onActive: (e, ctx) => { const dy = e.translationY; if (dy <= 0) { // slight resistance when pulling up translateY.value = ctx.start + dy * 0.2; return; } // 1:1 tracking for smooth dismissal translateY.value = ctx.start + dy; }, onEnd: (e) => { const shouldDismiss = e.translationY > DISMISS_THRESHOLD || e.velocityY > DISMISS_VELOCITY; if (shouldDismiss) { translateY.value = withTiming( height, { duration: 380, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94) }, (done) => { if (done) runOnJS(close)(); } ); } else { translateY.value = withSpring(0, { damping: 26, mass: 1, stiffness: 200, overshootClamping: false, }); } }, onFail: () => { translateY.value = withSpring(0, { damping: 26, stiffness: 200 }); }, onCancel: () => { translateY.value = withSpring(0, { damping: 26, stiffness: 200 }); }, }); const containerStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); const overlayStyle = useAnimatedStyle(() => ({ opacity: interpolate(translateY.value, [0, height * 0.5], [1, 0], Extrapolate.CLAMP), })); const coverStyle = useAnimatedStyle(() => ({ transform: [{ scale: coverScale.value }, { translateX: coverTranslateX.value }], })); const lyricsStyle = useAnimatedStyle(() => ({ opacity: lyricsOpacity.value, pointerEvents: lyricsOpacity.value > 0.5 ? 'auto' : 'none', })); const playerControlsStyle = useAnimatedStyle(() => ({ opacity: playerOpacity.value, pointerEvents: playerOpacity.value > 0.5 ? 'auto' : 'none', })); // onNext/onPrev MUST be defined BEFORE coverGesture (no hoisting for useCallback) const onNext = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); try { const ni = usePlayer.getState().nextTrack(); usePlayer.getState().setIndex(ni); } catch {} }, []); const onPrev = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); try { if (progress > 3) { seekTo(0); return; } const pi = usePlayer.getState().prevTrack(); usePlayer.getState().setIndex(pi); } catch {} }, [progress]); const onCopyTitle = useCallback(() => { try { Clipboard.setStringAsync(`${track?.artist} — ${track?.title}`); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); } catch {} }, [track]); // Horizontal swipe on cover → next/prev track const coverGesture = useAnimatedGestureHandler({ onStart: (_, ctx) => { ctx.startX = coverTranslateX.value; }, onActive: (e) => { if (Math.abs(e.translationY) > Math.abs(e.translationX) * 1.5) return; // 1:1 tracking for smooth feeling, scale down slightly coverTranslateX.value = e.translationX * 0.9; coverScale.value = interpolate(Math.abs(e.translationX), [0, width], [1, 0.85], Extrapolate.CLAMP); }, onEnd: (e) => { const THRESH = 90; // slightly higher threshold to prevent accidental skips if (e.translationX < -THRESH) { coverTranslateX.value = withTiming(-width, { duration: 250, easing: Easing.out(Easing.cubic) }, () => { runOnJS(onNext)(); coverTranslateX.value = width; coverTranslateX.value = withSpring(0, { damping: 22, stiffness: 180 }); coverScale.value = withSpring(1); }); } else if (e.translationX > THRESH) { coverTranslateX.value = withTiming(width, { duration: 250, easing: Easing.out(Easing.cubic) }, () => { runOnJS(onPrev)(); coverTranslateX.value = -width; coverTranslateX.value = withSpring(0, { damping: 22, stiffness: 180 }); coverScale.value = withSpring(1); }); } else { coverTranslateX.value = withSpring(0, { damping: 20, stiffness: 220 }); coverScale.value = withSpring(1); } }, onFail: () => { coverTranslateX.value = withSpring(0, { damping: 20 }); }, onCancel: () => { coverTranslateX.value = withSpring(0, { damping: 20 }); }, }); if (!track) return null; // cover is already declared above via dynColor const pct = duration > 0 ? progress / duration : 0; const liked = isLiked(track.id); const ext = (track.filename?.split('.').pop() || 'MP3').toUpperCase(); return ( {/* Blurred background */} {cover ? ( ) : ( )} {/* Dynamic color background overlay */} {/* Handle bar */} {/* Header */} Воспроизведение {track.album || 'Треки'} {/* Cover / Lyrics toggle area */} {/* Cover with horizontal swipe */} setLyricsMode(true)} onLongPress={onCopyTitle} delayLongPress={600}> {/* Lyrics overlay */} setLyricsMode(false)}> Закрыть текст {lrcLines.length > 0 ? lrcLines.map((line, i) => ( { lineHeights.current[i] = h; }} /> )) : plainLyrics ? {plainLyrics} : Текст недоступен } {/* Meta + like + share */} {track.title} {track.artist} { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setShareOpen(true); }} hitSlop={16} style={s.likeBtn} > { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={16} style={s.likeBtn} > {/* Format badge */} {ext} {/* Seeker */} setIsSeeking(true)} onSlidingComplete={(v) => { setIsSeeking(false); seekTo(v * duration); }} /> {fmt(progress)} -{fmt(duration - progress)} {/* Controls */} { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}> { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }} > {playing ? : } { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}> {/* Bottom row */} 0 || plainLyrics) && s.lyrBtnActive]}> {lyricsLoading ? 'Загрузка...' : 'Текст'} { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Alert.alert('VST Эффекты', 'Плагины для обработки звука будут добавлены в следующем обновлении.'); }}> VST / Звук {/* Share modal */} setShareOpen(false)} /> ); } // Animated lyric line - Apple Music style function LyricLine({ text, isActive, isPast, onLayout }: { text: string; isActive: boolean; isPast: boolean; onLayout: (h: number) => void; }) { const scale = useSharedValue(1); const opacity = useSharedValue(isPast ? 0.4 : isActive ? 1 : 0.3); const fontSize = useSharedValue(isActive ? 32 : 22); useEffect(() => { opacity.value = withTiming(isActive ? 1 : isPast ? 0.45 : 0.28, { duration: 350 }); scale.value = withSpring(isActive ? 1 : 0.92, { damping: 18, stiffness: 180 }); }, [isActive, isPast]); const style = useAnimatedStyle(() => ({ opacity: opacity.value, transform: [{ scale: scale.value }], })); return ( onLayout(e.nativeEvent.layout.height)} > {text} ); } function IconBtn({ onPress, active = false, scale = 0.8, children }: { onPress: () => void; active?: boolean; scale?: number; children: React.ReactNode; }) { const sc = useSharedValue(1); const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] })); return ( { sc.value = withSpring(scale, { damping: 10, mass: 0.6 }); }} onPressOut={() => { sc.value = withSpring(1, { damping: 10, mass: 0.6 }); }} hitSlop={16} style={[s.iconBtn, active && s.iconBtnActive]} > {children} ); } function ShuffleIcon({ active }: { active: boolean }) { const c = active ? Colors.accent : 'rgba(255,255,255,0.65)'; return ( ); } function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) { const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.65)'; return ( {repeat === 2 && ( 1 )} ); } function fmt(sec: number) { if (!sec || isNaN(sec)) return '0:00'; const m = Math.floor(sec / 60), s = Math.floor(sec % 60); return `${m}:${s < 10 ? '0' : ''}${s}`; } const s = StyleSheet.create({ root: { zIndex: 999 }, bgDim: { backgroundColor: 'rgba(0,0,0,0.65)' }, sheet: { flex: 1, paddingHorizontal: 28 }, handleBar: { alignItems: 'center', paddingBottom: 8 }, handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)' }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }, headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' }, headerLabel: { color: Colors.text, fontSize: 13, fontWeight: '700', letterSpacing: 0.2 }, headerSub: { color: 'rgba(255,255,255,0.5)', fontSize: 11, marginTop: 1 }, mainArea: { flex: 1, alignItems: 'center', justifyContent: 'center', position: 'relative' }, coverWrap: { alignItems: 'center', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center' }, coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.75, shadowRadius: 36 }, cover: { width: COVER_SIZE, height: COVER_SIZE, borderRadius: 16, backgroundColor: Colors.elevated }, lyricsOverlay: { justifyContent: 'flex-start', paddingTop: 16 }, lyricsCloseHint:{ alignItems: 'center', paddingVertical: 8, marginBottom: 8 }, lyricsCloseText:{ color: 'rgba(255,255,255,0.45)', fontSize: 12, fontWeight: '600' }, lyricsContent: { paddingHorizontal: 8, paddingBottom: 40 }, lyrLine: { fontSize: 22, fontWeight: '700', color: 'rgba(255,255,255,0.28)', lineHeight: 34, marginBottom: 8, textAlign: 'left' }, lyrLineActive: { fontSize: 28, color: '#ffffff', lineHeight: 40 }, lyrLinePast: { color: 'rgba(255,255,255,0.45)' }, plainLyrics: { fontSize: 17, lineHeight: 28, color: 'rgba(255,255,255,0.8)', fontWeight: '500' }, lyrEmpty: { color: 'rgba(255,255,255,0.4)', fontSize: 15, textAlign: 'center', marginTop: 60, fontWeight: '500' }, meta: { flexDirection: 'row', alignItems: 'center', marginTop: 12, marginBottom: 4, gap: 12 }, metaInfo: { flex: 1 }, trackTitle: { color: Colors.text, fontSize: 22, fontWeight: '800', letterSpacing: -0.4 }, artist: { color: 'rgba(255,255,255,0.55)', fontSize: 16, fontWeight: '500', marginTop: 4 }, likeBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }, formatRow: { flexDirection: 'row', marginBottom: 4 }, formatBadge: { backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6 }, formatText: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '700', letterSpacing: 1 }, seekWrap: { marginBottom: 8 }, slider: { width: '100%', height: 44 }, timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 }, timeText: { color: 'rgba(255,255,255,0.4)', fontSize: 12, fontWeight: '500' }, controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginVertical: 12 }, iconBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', borderRadius: 24 }, iconBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' }, playBtn: { width: 72, height: 72, borderRadius: 36, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.4, shadowRadius: 16, }, bottomRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 8, gap: 12 }, lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 20, paddingVertical: 12, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)', }, lyrBtnActive: { borderColor: `${Colors.accent}50`, backgroundColor: `${Colors.accent}18` }, lyrBtnText: { color: 'rgba(255,255,255,0.55)', fontSize: 14, fontWeight: '600' }, repBadge: { position: 'absolute', top: -3, right: -5, width: 11, height: 11, borderRadius: 6, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center', }, repBadgeText: { color: '#000', fontSize: 6, fontWeight: '900' }, });