import { useCallback, useEffect, useRef, useState } from 'react'; import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, useAnimatedGestureHandler, runOnJS, FadeIn, interpolate, } 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 Svg, { Path, Polyline, Line, Rect } 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'; const { width } = Dimensions.get('window'); const COVER = width - 48; 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 [lyricsOpen, setLyricsOpen] = useState(false); const [lrcLines, setLrcLines] = useState([]); const [plainLyrics, setPlain] = useState(null); const [lyricsLoading, setLyricsLoading] = useState(false); const lyricsScrollRef = useRef(null); const lineHeights = useRef([]); const translateY = useSharedValue(0); const coverScale = useSharedValue(1); useEffect(() => { coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 }); }, [playing]); useEffect(() => { setLrcLines([]); setPlain(null); setLyricsOpen(false); }, [track?.id]); const loadLyrics = useCallback(async () => { if (!track) return; if (lrcLines.length > 0 || plainLyrics) { setLyricsOpen(true); return; } setLyricsLoading(true); const data = await fetchLyrics(track.id); setLyricsLoading(false); if (data?.synced) setLrcLines(parseLrc(data.synced)); else if (data?.plain) setPlain(data.plain); setLyricsOpen(true); }, [track, lrcLines.length, plainLyrics]); const currentLineIdx = lrcLines.length > 0 ? lrcLines.reduce((acc, line, i) => line.time <= progress ? i : acc, 0) : -1; useEffect(() => { if (!lyricsOpen || currentLineIdx < 0) return; const y = lineHeights.current.slice(0, currentLineIdx).reduce((sum, h) => sum + h, 0); lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 120), animated: true }); }, [currentLineIdx, lyricsOpen]); const gestureHandler = useAnimatedGestureHandler({ onStart: (_, ctx) => { ctx.startY = translateY.value; }, onActive: (e, ctx) => { if (e.translationY > 0) translateY.value = ctx.startY + e.translationY; }, onEnd: (e) => { if (e.translationY > 100 || e.velocityY > 700) runOnJS(close)(); else translateY.value = withSpring(0, { damping: 20 }); }, }); const containerStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }] })); const overlayOpacity = useAnimatedStyle(() => ({ opacity: interpolate(translateY.value, [0, 300], [1, 0]) })); const coverStyle = useAnimatedStyle(() => ({ transform: [{ scale: coverScale.value }] })); const close = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setExpanded(false); }, [setExpanded]); const onNext = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); const ni = usePlayer.getState().nextTrack(); usePlayer.getState().setIndex(ni); }, []); const onPrev = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); if (progress > 3) { seekTo(0); return; } const pi = usePlayer.getState().prevTrack(); usePlayer.getState().setIndex(pi); }, [progress]); if (!track) return null; const cover = coverUrl(track.cover_url); const pct = duration > 0 ? progress / duration : 0; const liked = isLiked(track.id); return ( {/* Blurred background */} {/* Top bar */} Сейчас играет {/* Cover */} {/* Meta */} {track.title} {track.artist} { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={12}> {/* Seek */} seekTo(v * duration)} /> {fmt(progress)} {fmt(duration)} {/* 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}> {/* Lyrics button */} {lyricsLoading ? 'Загрузка…' : 'Текст'} {/* Lyrics sheet */} {lyricsOpen && ( setLyricsOpen(false)} /> setLyricsOpen(false)} style={s.lyrHandleBar}> {cover ? : } {track.title} {track.artist} {lrcLines.length > 0 ? lrcLines.map((line, i) => ( { lineHeights.current[i] = e.nativeEvent.layout.height; }}> {line.text} )) : plainLyrics ? {plainLyrics} : Текст не найден} )} ); } function CtlBtn({ onPress, active = false, children }: { onPress: () => void; active?: boolean; children: React.ReactNode }) { const sc = useSharedValue(1); const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] })); return ( { sc.value = withSpring(0.82, { damping: 12 }); }} onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} hitSlop={12} style={[s.ctlBtn, active && s.ctlBtnActive]}> {children} ); } function ShuffleIcon({ active }: { active: boolean }) { const c = active ? Colors.accent : 'rgba(255,255,255,0.5)'; return ( ); } function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) { const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.5)'; return ( {repeat === 2 && 1} ); } function fmt(sec: number) { 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: 24 }, topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 8 }, topLabel: { color: 'rgba(255,255,255,0.38)', fontSize: 11, fontWeight: '600', letterSpacing: 1.2, textTransform: 'uppercase' }, closeBtn: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.1)', alignItems: 'center', justifyContent: 'center' }, coverWrap: { alignItems: 'center', marginVertical: 20 }, coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.7, shadowRadius: 32 }, cover: { width: COVER, height: COVER, borderRadius: Radius.xl, backgroundColor: Colors.elevated }, metaRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 12 }, metaText: { flex: 1 }, trackTitle: { color: Colors.text, fontSize: 22, fontWeight: '700', letterSpacing: -0.3 }, artist: { color: 'rgba(255,255,255,0.55)', fontSize: 17, marginTop: 4 }, seekWrap: { marginBottom: 8 }, timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 }, timeText: { color: 'rgba(255,255,255,0.35)', fontSize: Font.xs }, controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 20 }, ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full }, ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' }, playBtn: { width: 60, height: 60, borderRadius: 30, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center' }, bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }, lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 14, paddingVertical: 8, borderRadius: Radius.full }, lyrBtnText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '600' }, repBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center' }, repBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' }, lyrSheet: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' }, lyrInner: { height: '70%', borderTopLeftRadius: 20, borderTopRightRadius: 20, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.1)' }, lyrHandleBar: { alignItems: 'center', paddingTop: 12, paddingBottom: 4 }, lyrHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.25)' }, lyrHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 1, borderColor: 'rgba(255,255,255,0.06)' }, lyrThumb: { width: 40, height: 40, borderRadius: 8, flexShrink: 0 }, lyrTrackTitle: { fontSize: 13, fontWeight: '600', color: '#fff' }, lyrArtist: { fontSize: 11, color: 'rgba(255,255,255,0.55)' }, lyrScroll: { flex: 1 }, lyrLine: { fontSize: 20, lineHeight: 32, color: 'rgba(255,255,255,0.28)', fontWeight: '600', marginBottom: 8, textAlign: 'center' }, lyrLineActive: { fontSize: 28, lineHeight: 42, color: '#fff', marginBottom: 10 }, plainLyrics: { fontSize: 15, lineHeight: 26, color: 'rgba(255,255,255,0.75)', textAlign: 'center' }, lyrEmpty: { color: 'rgba(255,255,255,0.35)', fontSize: 14, textAlign: 'center', marginTop: 40 }, });