Add files via upload

This commit is contained in:
Savsis 2026-05-05 18:17:09 +05:00 • committed by GitHub
parent d612a65f81
commit 5748d2d13c
31 changed files with 3197 additions and 2492 deletions

View file

@ -1,73 +1,73 @@
import { useState, useEffect } from 'react';
import { Text, ViewStyle } from 'react-native';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Radius } from '../constants/theme';
// 12 distinct dark gradients — one is assigned per title hash
const GRADIENTS: [string, string][] = [
['#12234a', '#1e4080'],
['#1a0e3a', '#3d1a7a'],
['#0e2a3a', '#1a5a7a'],
['#2a0e1e', '#6a1a3e'],
['#0e2a1e', '#1a6a3e'],
['#2a1e0e', '#7a4a1e'],
['#1e0e2a', '#4a1e6a'],
['#0e1e2a', '#1e4a6a'],
['#2a0e2a', '#6a1a6a'],
['#1e2a0e', '#4a6a1e'],
['#2a1e1e', '#6a3a3a'],
['#0e2a2a', '#1a6a6a'],
];
function textHash(s: string): number {
let h = 0;
for (const c of s) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0;
return Math.abs(h);
}
interface Props {
uri: string | null;
title: string;
size: number;
radius?: number;
style?: ViewStyle;
blurRadius?: number;
}
export default function CoverImage({ uri, title, size, radius = Radius.sm, style, blurRadius }: Props) {
const [failed, setFailed] = useState(false);
// Reset error state when URI changes (new track loaded)
useEffect(() => { setFailed(false); }, [uri]);
if (uri && !failed) {
return (
<Image
source={{ uri }}
style={[{ width: size, height: size, borderRadius: radius, backgroundColor: '#1a1a1a' }, style]}
contentFit="cover"
transition={250}
blurRadius={blurRadius}
onError={() => setFailed(true)}
/>
);
}
const [c1, c2] = GRADIENTS[textHash(title || '') % GRADIENTS.length];
const letter = (title || '?').trim().toUpperCase()[0];
const fontSize = size * 0.36;
return (
<LinearGradient
colors={[c1, c2]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[{ width: size, height: size, borderRadius: radius, alignItems: 'center', justifyContent: 'center' }, style]}
>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize, fontWeight: '800', textAlign: 'center' }}>
{letter}
</Text>
</LinearGradient>
);
}
import { useState, useEffect } from 'react';
import { Text, ViewStyle } from 'react-native';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Radius } from '../constants/theme';
// 12 distinct dark gradients — one is assigned per title hash
const GRADIENTS: [string, string][] = [
['#12234a', '#1e4080'],
['#1a0e3a', '#3d1a7a'],
['#0e2a3a', '#1a5a7a'],
['#2a0e1e', '#6a1a3e'],
['#0e2a1e', '#1a6a3e'],
['#2a1e0e', '#7a4a1e'],
['#1e0e2a', '#4a1e6a'],
['#0e1e2a', '#1e4a6a'],
['#2a0e2a', '#6a1a6a'],
['#1e2a0e', '#4a6a1e'],
['#2a1e1e', '#6a3a3a'],
['#0e2a2a', '#1a6a6a'],
];
function textHash(s: string): number {
let h = 0;
for (const c of s) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0;
return Math.abs(h);
}
interface Props {
uri: string | null;
title: string;
size: number;
radius?: number;
style?: ViewStyle;
blurRadius?: number;
}
export default function CoverImage({ uri, title, size, radius = Radius.sm, style, blurRadius }: Props) {
const [failed, setFailed] = useState(false);
// Reset error state when URI changes (new track loaded)
useEffect(() => { setFailed(false); }, [uri]);
if (uri && !failed) {
return (
<Image
source={{ uri }}
style={[{ width: size, height: size, borderRadius: radius, backgroundColor: '#1a1a1a' }, style]}
contentFit="cover"
transition={250}
blurRadius={blurRadius}
onError={() => setFailed(true)}
/>
);
}
const [c1, c2] = GRADIENTS[textHash(title || '') % GRADIENTS.length];
const letter = (title || '?').trim().toUpperCase()[0];
const fontSize = size * 0.36;
return (
<LinearGradient
colors={[c1, c2]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[{ width: size, height: size, borderRadius: radius, alignItems: 'center', justifyContent: 'center' }, style]}
>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize, fontWeight: '800', textAlign: 'center' }}>
{letter}
</Text>
</LinearGradient>
);
}

View file

@ -0,0 +1,101 @@
import { View, StyleSheet, Pressable, Text, ActivityIndicator } from 'react-native';
import Animated, { FadeIn, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { useMemo } from 'react';
import Svg, { Path } from 'react-native-svg';
import * as Haptics from 'expo-haptics';
import { Colors, Font } from '../constants/theme';
export interface DownloadButtonProps {
trackId: number;
isDownloaded?: boolean;
downloadProgress?: number; // 0-1
onDownload?: () => void;
onDelete?: () => void;
size?: 'sm' | 'md' | 'lg';
}
export default function DownloadButton({
trackId,
isDownloaded = false,
downloadProgress = 0,
onDownload,
onDelete,
size = 'md',
}: DownloadButtonProps) {
const sizing = useMemo(() => {
const map = { sm: 20, md: 24, lg: 32 };
return map[size];
}, [size]);
const handlePress = async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
if (isDownloaded) {
onDelete?.();
} else {
onDownload?.();
}
};
const isLoading = downloadProgress > 0 && downloadProgress < 1;
if (isLoading) {
return (
<Animated.View entering={FadeIn}>
<View style={[styles.container, { width: sizing, height: sizing }]}>
<ActivityIndicator color={Colors.accent} size={sizing * 0.6} />
{downloadProgress > 0.2 && (
<Text style={[styles.progress, { fontSize: sizing * 0.3 }]}>
{Math.round(downloadProgress * 100)}%
</Text>
)}
</View>
</Animated.View>
);
}
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [
styles.button,
{
width: sizing,
height: sizing,
opacity: pressed ? 0.6 : 1,
},
]}
>
{isDownloaded ? (
<Svg width={sizing} height={sizing} viewBox="0 0 24 24" fill="none">
<Path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"
fill={Colors.accent}
/>
</Svg>
) : (
<Svg width={sizing} height={sizing} viewBox="0 0 24 24" fill="none">
<Path
d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"
fill={Colors.text2}
/>
</Svg>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
justifyContent: 'center',
alignItems: 'center',
},
container: {
justifyContent: 'center',
alignItems: 'center',
},
progress: {
color: Colors.accent,
fontFamily: Font.family.medium,
marginTop: 2,
},
});

View file

@ -1,129 +1,129 @@
import { useCallback } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Animated, {
useSharedValue, useAnimatedStyle, withSpring,
FadeInDown, FadeOutDown, useAnimatedGestureHandler, runOnJS,
} 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 } from 'react-native-svg';
import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme';
import { usePlayer, useLikes } from '../store';
import { togglePlay, seekTo } from '../hooks/useAudio';
import { coverUrl } from '../services/api';
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 gestureHandler = useAnimatedGestureHandler({
onActive: (e) => {
if (e.translationY < 0) {
translateY.value = Math.max(e.translationY, -80);
opacity.value = 1 + e.translationY / 80;
}
},
onEnd: (e) => {
if (e.translationY < -40 || e.velocityY < -400) runOnJS(openPlayer)();
translateY.value = withSpring(0);
opacity.value = withSpring(1);
},
});
const animStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: opacity.value,
}));
const openPlayer = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setExpanded(true);
}, [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);
const pi = usePlayer.getState().prevTrack();
usePlayer.getState().setIndex(pi);
}, []);
if (!track || expanded) return null;
const cover = coverUrl(track.cover_url);
const liked = isLiked(track.id);
const pct = duration > 0 ? (progress / duration) * 100 : 0;
return (
<PanGestureHandler onGestureEvent={gestureHandler}>
<Animated.View
style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
entering={FadeInDown.springify()}
exiting={FadeOutDown.springify()}
>
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
<View style={[StyleSheet.absoluteFill, s.bgTint]} />
{/* Progress line */}
<View style={s.progressBg}>
<View style={[s.progressFill, { width: `${pct}%` as any }]} />
</View>
<Pressable style={s.row} onPress={openPlayer} android_ripple={null}>
<Image
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
style={s.cover}
contentFit="cover"
transition={200}
/>
<View style={s.info}>
<Text style={s.title} numberOfLines={1}>{track.title}</Text>
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
</View>
<View style={s.controls}>
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}>
<Svg width={18} height={18} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text2} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
</Pressable>
<Pressable onPress={(e) => { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}>
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></Svg>
</Pressable>
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}>
{playing
? <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg>
: <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>}
</Pressable>
<Pressable onPress={(e) => { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}>
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg>
</Pressable>
</View>
</Pressable>
</Animated.View>
</PanGestureHandler>
);
}
const s = StyleSheet.create({
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderTopLeftRadius: 14, borderTopRightRadius: 14 },
bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' },
progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 },
progressFill:{ height: 2, backgroundColor: Colors.accent },
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 },
cover: { width: 44, height: 44, borderRadius: 9, backgroundColor: Colors.elevated, flexShrink: 0 },
info: { flex: 1, minWidth: 0 },
title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
controls: { flexDirection: 'row', alignItems: 'center' },
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
});
import { useCallback } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Animated, {
useSharedValue, useAnimatedStyle, withSpring,
FadeInDown, FadeOutDown, useAnimatedGestureHandler, runOnJS,
} 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 } from 'react-native-svg';
import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme';
import { usePlayer, useLikes } from '../store';
import { togglePlay, seekTo } from '../hooks/useAudio';
import { coverUrl } from '../services/api';
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 gestureHandler = useAnimatedGestureHandler({
onActive: (e) => {
if (e.translationY < 0) {
translateY.value = Math.max(e.translationY, -80);
opacity.value = 1 + e.translationY / 80;
}
},
onEnd: (e) => {
if (e.translationY < -40 || e.velocityY < -400) runOnJS(openPlayer)();
translateY.value = withSpring(0);
opacity.value = withSpring(1);
},
});
const animStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: opacity.value,
}));
const openPlayer = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setExpanded(true);
}, [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);
const pi = usePlayer.getState().prevTrack();
usePlayer.getState().setIndex(pi);
}, []);
if (!track || expanded) return null;
const cover = coverUrl(track.cover_url);
const liked = isLiked(track.id);
const pct = duration > 0 ? (progress / duration) * 100 : 0;
return (
<PanGestureHandler onGestureEvent={gestureHandler}>
<Animated.View
style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
entering={FadeInDown.springify()}
exiting={FadeOutDown.springify()}
>
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
<View style={[StyleSheet.absoluteFill, s.bgTint]} />
{/* Progress line */}
<View style={s.progressBg}>
<View style={[s.progressFill, { width: `${pct}%` as any }]} />
</View>
<Pressable style={s.row} onPress={openPlayer} android_ripple={null}>
<Image
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
style={s.cover}
contentFit="cover"
transition={200}
/>
<View style={s.info}>
<Text style={s.title} numberOfLines={1}>{track.title}</Text>
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
</View>
<View style={s.controls}>
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}>
<Svg width={18} height={18} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text2} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
</Pressable>
<Pressable onPress={(e) => { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}>
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></Svg>
</Pressable>
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}>
{playing
? <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg>
: <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>}
</Pressable>
<Pressable onPress={(e) => { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}>
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg>
</Pressable>
</View>
</Pressable>
</Animated.View>
</PanGestureHandler>
);
}
const s = StyleSheet.create({
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderTopLeftRadius: 14, borderTopRightRadius: 14 },
bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' },
progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 },
progressFill:{ height: 2, backgroundColor: Colors.accent },
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 },
cover: { width: 44, height: 44, borderRadius: 9, backgroundColor: Colors.elevated, flexShrink: 0 },
info: { flex: 1, minWidth: 0 },
title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
controls: { flexDirection: 'row', alignItems: 'center' },
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
});

View file

@ -1,316 +1,480 @@
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<LrcLine[]>([]);
const [plainLyrics, setPlain] = useState<string | null>(null);
const [lyricsLoading, setLyricsLoading] = useState(false);
const lyricsScrollRef = useRef<ScrollView>(null);
const lineHeights = useRef<number[]>([]);
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<any, { startY: number }>({
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 (
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}>
{/* Blurred background */}
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={80} />
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
<BlurView intensity={20} tint="dark" style={StyleSheet.absoluteFill} />
<PanGestureHandler onGestureEvent={gestureHandler}>
<Animated.View style={[s.sheet, containerStyle, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 16 }]}>
{/* Top bar */}
<View style={s.topBar}>
<Text style={s.topLabel}>Сейчас играет</Text>
<Pressable onPress={close} hitSlop={16} style={s.closeBtn}>
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none">
<Path d="M18 6L6 18M6 6l12 12" stroke="rgba(255,255,255,0.5)" strokeWidth={2} strokeLinecap="round" />
</Svg>
</Pressable>
</View>
{/* Cover */}
<View style={s.coverWrap}>
<Animated.View style={[s.coverShadow, coverStyle]}>
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={s.cover} contentFit="cover" transition={300} />
</Animated.View>
</View>
{/* Meta */}
<View style={s.metaRow}>
<View style={s.metaText}>
<Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text>
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
</View>
<Pressable onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={12}>
<Svg width={22} height={22} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : 'rgba(255,255,255,0.4)'} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
</Pressable>
</View>
{/* Seek */}
<View style={s.seekWrap}>
<Slider style={{ width: '100%', height: 32 }} minimumValue={0} maximumValue={1} value={pct} minimumTrackTintColor={Colors.text} maximumTrackTintColor="rgba(255,255,255,0.2)" thumbTintColor={Colors.text} onSlidingComplete={(v) => seekTo(v * duration)} />
<View style={s.timeRow}>
<Text style={s.timeText}>{fmt(progress)}</Text>
<Text style={s.timeText}>{fmt(duration)}</Text>
</View>
</View>
{/* Controls */}
<View style={s.controls}>
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
<ShuffleIcon active={shuffle} />
</CtlBtn>
<CtlBtn onPress={onPrev}>
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
<Rect x={5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
<Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} />
</Svg>
</CtlBtn>
<Pressable style={s.playBtn} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}>
{playing
? <Svg width={26} height={26} viewBox="0 0 24 24"><Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /><Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /></Svg>
: <Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" /></Svg>}
</Pressable>
<CtlBtn onPress={onNext}>
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
<Rect x={20.5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
<Path d="M6 6l12.5 8L6 22V6z" fill={Colors.text} />
</Svg>
</CtlBtn>
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
<RepeatIcon repeat={repeat} />
</CtlBtn>
</View>
{/* Lyrics button */}
<View style={s.bottomRow}>
<Pressable style={s.lyrBtn} onPress={loadLyrics}>
<Svg width={14} height={14} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.4)'} strokeWidth={2} viewBox="0 0 24 24">
<Line x1={3} y1={8} x2={21} y2={8} /><Line x1={3} y1={12} x2={21} y2={12} /><Line x1={3} y1={16} x2={15} y2={16} />
</Svg>
<Text style={[s.lyrBtnText, lyricsOpen && { color: Colors.accent }]}>
{lyricsLoading ? 'Загрузка…' : 'Текст'}
</Text>
</Pressable>
</View>
</Animated.View>
</PanGestureHandler>
{/* Lyrics sheet */}
{lyricsOpen && (
<View style={s.lyrSheet}>
<Pressable style={StyleSheet.absoluteFill} onPress={() => setLyricsOpen(false)} />
<BlurView intensity={40} tint="dark" style={s.lyrInner}>
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
<View style={s.lyrHandle} />
</Pressable>
<View style={s.lyrHeader}>
{cover
? <Image source={{ uri: cover }} style={s.lyrThumb} contentFit="cover" />
: <View style={[s.lyrThumb, { backgroundColor: Colors.elevated }]} />}
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={s.lyrTrackTitle} numberOfLines={1}>{track.title}</Text>
<Text style={s.lyrArtist} numberOfLines={1}>{track.artist}</Text>
</View>
</View>
<ScrollView ref={lyricsScrollRef} style={s.lyrScroll} contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
{lrcLines.length > 0
? lrcLines.map((line, i) => (
<Text key={i} style={[s.lyrLine, i === currentLineIdx && s.lyrLineActive]} onLayout={(e) => { lineHeights.current[i] = e.nativeEvent.layout.height; }}>
{line.text}
</Text>
))
: plainLyrics
? <Text style={s.plainLyrics}>{plainLyrics}</Text>
: <Text style={s.lyrEmpty}>Текст не найден</Text>}
</ScrollView>
</BlurView>
</View>
)}
</Animated.View>
);
}
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 (
<Animated.View style={st}>
<Pressable onPress={onPress} onPressIn={() => { sc.value = withSpring(0.82, { damping: 12 }); }} onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} hitSlop={12} style={[s.ctlBtn, active && s.ctlBtnActive]}>
{children}
</Pressable>
</Animated.View>
);
}
function ShuffleIcon({ active }: { active: boolean }) {
const c = active ? Colors.accent : 'rgba(255,255,255,0.5)';
return (
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M4 20L21 3" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<Polyline points="21,16 21,21 16,21" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
}
function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.5)';
return (
<View>
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M3 11V9a4 4 0 0 1 4-4h14" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
{repeat === 2 && <View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>}
</View>
);
}
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 },
});
import { useCallback, useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView, ViewStyle } from 'react-native';
import Animated, {
useSharedValue, useAnimatedStyle, withSpring, withDecay,
useAnimatedGestureHandler, runOnJS, FadeIn, interpolate, Easing, withTiming,
} 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, 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';
const { width, height } = Dimensions.get('window');
const COVER = width - 48;
const MIN_SWIPE_DISTANCE = 50;
const MAX_SWIPE_DISTANCE = height * 0.4;
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<LrcLine[]>([]);
const [plainLyrics, setPlain] = useState<string | null>(null);
const [lyricsLoading, setLyricsLoading] = useState(false);
const lyricsScrollRef = useRef<ScrollView>(null);
const lineHeights = useRef<number[]>([]);
const gestureRef = useRef<any>(null);
const translateY = useSharedValue(0);
const coverScale = useSharedValue(1);
const coverRotate = useSharedValue(0);
useEffect(() => {
coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 14, mass: 1, stiffness: 100 });
}, [playing]);
useEffect(() => {
setLrcLines([]); setPlain(null); setLyricsOpen(false);
translateY.value = 0;
}, [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 - 100), animated: true });
}, [currentLineIdx, lyricsOpen]);
const gestureHandler = useAnimatedGestureHandler<any, { startY: number, startX: number }>({
onStart: (_, ctx) => {
ctx.startY = translateY.value;
ctx.startX = 0;
},
onActive: (e, ctx) => {
try {
// Only allow vertical swipe, prevent diagonal
if (Math.abs(e.translationX) > Math.abs(e.translationY) * 0.5) return;
if (e.translationY > 0) {
const clamped = Math.min(e.translationY, MAX_SWIPE_DISTANCE);
translateY.value = ctx.startY + clamped;
coverRotate.value = withTiming(clamped / 100, { duration: 50, easing: Easing.linear });
}
} catch (err) {
console.warn('Gesture error:', err);
}
},
onEnd: (e) => {
try {
const shouldClose = e.translationY > MIN_SWIPE_DISTANCE || e.velocityY > 500;
if (shouldClose) {
translateY.value = withTiming(height, {
duration: 300,
easing: Easing.out(Easing.ease)
}, () => {
runOnJS(close)();
});
coverRotate.value = withTiming(0, { duration: 300 });
} else {
translateY.value = withSpring(0, {
damping: 15,
mass: 1,
stiffness: 120
});
coverRotate.value = withTiming(0, { duration: 200 });
}
} catch (err) {
console.warn('Gesture end error:', err);
translateY.value = withSpring(0, { damping: 15, mass: 1 });
}
},
});
const containerStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }]
}));
const overlayOpacity = useAnimatedStyle(() => ({
opacity: interpolate(translateY.value, [0, MAX_SWIPE_DISTANCE], [1, 0.2])
}));
const coverStyle = useAnimatedStyle(() => ({
transform: [
{ scale: coverScale.value },
{ rotateZ: `${coverRotate.value}deg` }
]
}));
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 (
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}>
{/* Blurred background with better gradient */}
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={100} />
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
<BlurView intensity={25} tint="dark" style={StyleSheet.absoluteFill} />
<PanGestureHandler ref={gestureRef} onGestureEvent={gestureHandler}>
<Animated.View style={[s.sheet, containerStyle, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 16 }]}>
{/* Top bar with better design */}
<View style={s.topBar}>
<Text style={s.topLabel}>Сейчас играет</Text>
<Pressable onPress={close} hitSlop={20} style={s.closeBtn}>
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
<Path d="M18 6L6 18M6 6l12 12" stroke="rgba(255,255,255,0.6)" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
</Pressable>
</View>
{/* Cover with better shadow */}
<View style={s.coverWrap}>
<Animated.View style={[s.coverShadow, coverStyle]}>
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={s.cover} contentFit="cover" transition={300} />
</Animated.View>
</View>
{/* Meta with better spacing */}
<View style={s.metaRow}>
<View style={s.metaText}>
<Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text>
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
</View>
<Pressable onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={16}>
<Svg width={24} height={24} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : 'rgba(255,255,255,0.5)'} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
</Pressable>
</View>
{/* Seek with gradient track */}
<View style={s.seekWrap}>
<View style={s.sliderContainer}>
<Slider
style={{ flex: 1, height: 40 }}
minimumValue={0}
maximumValue={1}
value={pct}
minimumTrackTintColor={Colors.accent}
maximumTrackTintColor="rgba(255,255,255,0.15)"
thumbTintColor={Colors.text}
onSlidingComplete={(v) => seekTo(v * duration)}
/>
</View>
<View style={s.timeRow}>
<Text style={s.timeText}>{fmt(progress)}</Text>
<Text style={s.timeText}>{fmt(duration - progress)}</Text>
</View>
</View>
{/* Controls with better styling */}
<View style={s.controls}>
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
<ShuffleIcon active={shuffle} />
</CtlBtn>
<CtlBtn onPress={onPrev}>
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
<Rect x={5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
<Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} />
</Svg>
</CtlBtn>
<Pressable style={s.playBtn} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}>
{playing
? <Svg width={28} height={28} viewBox="0 0 24 24"><Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /><Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /></Svg>
: <Svg width={28} height={28} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" /></Svg>}
</Pressable>
<CtlBtn onPress={onNext}>
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
<Rect x={20.5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
<Path d="M6 6l12.5 8L6 22V6z" fill={Colors.text} />
</Svg>
</CtlBtn>
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
<RepeatIcon repeat={repeat} />
</CtlBtn>
</View>
{/* Lyrics button */}
<View style={s.bottomRow}>
<Pressable style={[s.lyrBtn, lyricsOpen && s.lyrBtnActive]} onPress={loadLyrics}>
<Svg width={15} height={15} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.5)'} strokeWidth={2} viewBox="0 0 24 24">
<Line x1={3} y1={8} x2={21} y2={8} strokeLinecap="round" /><Line x1={3} y1={12} x2={21} y2={12} strokeLinecap="round" /><Line x1={3} y1={16} x2={15} y2={16} strokeLinecap="round" />
</Svg>
<Text style={[s.lyrBtnText, lyricsOpen && { color: Colors.accent }]}>
{lyricsLoading ? 'Загрузка…' : 'Текст'}
</Text>
</Pressable>
</View>
</Animated.View>
</PanGestureHandler>
{/* Lyrics sheet with better design */}
{lyricsOpen && (
<View style={s.lyrSheet}>
<Pressable style={StyleSheet.absoluteFill} onPress={() => setLyricsOpen(false)} />
<BlurView intensity={50} tint="dark" style={s.lyrInner}>
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
<View style={s.lyrHandle} />
</Pressable>
<View style={s.lyrHeader}>
{cover
? <Image source={{ uri: cover }} style={s.lyrThumb} contentFit="cover" />
: <View style={[s.lyrThumb, { backgroundColor: Colors.elevated }]} />}
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={s.lyrTrackTitle} numberOfLines={1}>{track.title}</Text>
<Text style={s.lyrArtist} numberOfLines={1}>{track.artist}</Text>
</View>
</View>
<ScrollView ref={lyricsScrollRef} style={s.lyrScroll} contentContainerStyle={{ paddingVertical: 40, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false} scrollEventThrottle={16}>
{lrcLines.length > 0
? lrcLines.map((line, i) => (
<Animated.Text
key={i}
style={[
s.lyrLine,
i === currentLineIdx && s.lyrLineActive,
i < currentLineIdx && s.lyrLinePassed
]}
onLayout={(e) => { lineHeights.current[i] = e.nativeEvent.layout.height; }}
>
{line.text}
</Animated.Text>
))
: plainLyrics
? <Text style={s.plainLyrics}>{plainLyrics}</Text>
: <Text style={s.lyrEmpty}>Текст не найден</Text>}
</ScrollView>
</BlurView>
</View>
)}
</Animated.View>
);
}
<Text style={s.timeText}>{fmt(progress)}</Text>
<Text style={s.timeText}>{fmt(duration)}</Text>
</View>
</View>
{/* Controls */}
<View style={s.controls}>
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
<ShuffleIcon active={shuffle} />
</CtlBtn>
<CtlBtn onPress={onPrev}>
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
<Rect x={5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
<Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} />
</Svg>
</CtlBtn>
<Pressable style={s.playBtn} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}>
{playing
? <Svg width={26} height={26} viewBox="0 0 24 24"><Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /><Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /></Svg>
: <Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" /></Svg>}
</Pressable>
<CtlBtn onPress={onNext}>
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
<Rect x={20.5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
<Path d="M6 6l12.5 8L6 22V6z" fill={Colors.text} />
</Svg>
</CtlBtn>
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
<RepeatIcon repeat={repeat} />
</CtlBtn>
</View>
{/* Lyrics button */}
<View style={s.bottomRow}>
<Pressable style={s.lyrBtn} onPress={loadLyrics}>
<Svg width={14} height={14} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.4)'} strokeWidth={2} viewBox="0 0 24 24">
<Line x1={3} y1={8} x2={21} y2={8} /><Line x1={3} y1={12} x2={21} y2={12} /><Line x1={3} y1={16} x2={15} y2={16} />
</Svg>
<Text style={[s.lyrBtnText, lyricsOpen && { color: Colors.accent }]}>
{lyricsLoading ? 'Загрузка…' : 'Текст'}
</Text>
</Pressable>
</View>
</Animated.View>
</PanGestureHandler>
{/* Lyrics sheet */}
{lyricsOpen && (
<View style={s.lyrSheet}>
<Pressable style={StyleSheet.absoluteFill} onPress={() => setLyricsOpen(false)} />
<BlurView intensity={40} tint="dark" style={s.lyrInner}>
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
<View style={s.lyrHandle} />
</Pressable>
<View style={s.lyrHeader}>
{cover
? <Image source={{ uri: cover }} style={s.lyrThumb} contentFit="cover" />
: <View style={[s.lyrThumb, { backgroundColor: Colors.elevated }]} />}
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={s.lyrTrackTitle} numberOfLines={1}>{track.title}</Text>
<Text style={s.lyrArtist} numberOfLines={1}>{track.artist}</Text>
</View>
</View>
<ScrollView ref={lyricsScrollRef} style={s.lyrScroll} contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
{lrcLines.length > 0
? lrcLines.map((line, i) => (
<Text key={i} style={[s.lyrLine, i === currentLineIdx && s.lyrLineActive]} onLayout={(e) => { lineHeights.current[i] = e.nativeEvent.layout.height; }}>
{line.text}
</Text>
))
: plainLyrics
? <Text style={s.plainLyrics}>{plainLyrics}</Text>
: <Text style={s.lyrEmpty}>Текст не найден</Text>}
</ScrollView>
</BlurView>
</View>
)}
</Animated.View>
);
}
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 (
<Animated.View style={st}>
<Pressable
onPress={onPress}
onPressIn={() => { sc.value = withSpring(0.75, { damping: 10, mass: 0.8 }); }}
onPressOut={() => { sc.value = withSpring(1, { damping: 10, mass: 0.8 }); }}
hitSlop={14}
style={[s.ctlBtn, active && s.ctlBtnActive]}
>
{children}
</Pressable>
</Animated.View>
);
}
function ShuffleIcon({ active }: { active: boolean }) {
const c = active ? Colors.accent : 'rgba(255,255,255,0.6)';
return (
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M4 20L21 3" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Polyline points="21,16 21,21 16,21" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
}
function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.6)';
return (
<View>
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M3 11V9a4 4 0 0 1 4-4h14" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
{repeat === 2 && <View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>}
</View>
);
}
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.7)' },
sheet: { flex: 1, paddingHorizontal: 24 },
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 10, marginBottom: 8 },
topLabel: { color: 'rgba(255,255,255,0.4)', fontSize: 11, fontWeight: '700', letterSpacing: 1.3, textTransform: 'uppercase' },
closeBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.12)', alignItems: 'center', justifyContent: 'center' },
coverWrap: { alignItems: 'center', marginVertical: 24, marginBottom: 32 },
coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 28 }, shadowOpacity: 0.8, shadowRadius: 40 },
cover: { width: COVER, height: COVER, borderRadius: Radius.xl, backgroundColor: Colors.elevated, overflow: 'hidden' },
metaRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 16, marginBottom: 16 },
metaText: { flex: 1 },
trackTitle: { color: Colors.text, fontSize: 24, fontWeight: '800', letterSpacing: -0.5, lineHeight: 30 },
artist: { color: 'rgba(255,255,255,0.6)', fontSize: 16, marginTop: 6, fontWeight: '500' },
seekWrap: { marginBottom: 12 },
sliderContainer: { height: 40, justifyContent: 'center' },
timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 2 },
timeText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '500' },
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 6, marginBottom: 24, marginTop: 16 },
ctlBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', borderRadius: 24 },
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.08)' },
playBtn: { width: 64, height: 64, borderRadius: 32, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center', shadowColor: Colors.accent, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 12 },
bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.1)', paddingHorizontal: 16, paddingVertical: 10, borderRadius: Radius.full, borderWidth: 1, borderColor: 'rgba(255,255,255,0.05)' },
lyrBtnActive: { backgroundColor: `${Colors.accent}15`, borderColor: `${Colors.accent}40` },
lyrBtnText: { color: 'rgba(255,255,255,0.5)', fontSize: Font.sm, fontWeight: '600' },
repBadge: { position: 'absolute', top: -4, right: -4, width: 12, height: 12, borderRadius: 6, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center', shadowColor: Colors.accent, shadowOpacity: 0.5, shadowRadius: 4 },
repBadgeText: { color: '#000', fontSize: 6, fontWeight: '900' },
// Lyrics sheet
lyrSheet: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' },
lyrInner: { height: '75%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.08)' },
lyrHandleBar: { alignItems: 'center', paddingTop: 14, paddingBottom: 8 },
lyrHandle: { width: 40, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.28)' },
lyrHeader: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingHorizontal: 20, paddingVertical: 14, borderBottomWidth: 1, borderColor: 'rgba(255,255,255,0.06)' },
lyrThumb: { width: 44, height: 44, borderRadius: 10, flexShrink: 0, shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 6 },
lyrTrackTitle: { fontSize: 14, fontWeight: '700', color: '#fff' },
lyrArtist: { fontSize: 12, color: 'rgba(255,255,255,0.6)', marginTop: 2 },
lyrScroll: { flex: 1 },
lyrLine: { fontSize: 18, lineHeight: 30, color: 'rgba(255,255,255,0.25)', fontWeight: '600', marginBottom: 12, textAlign: 'center' },
lyrLineActive: { fontSize: 32, lineHeight: 44, color: Colors.accent, marginBottom: 16, fontWeight: '800' },
lyrLinePassed: { color: 'rgba(255,255,255,0.35)' },
plainLyrics: { fontSize: 16, lineHeight: 28, color: 'rgba(255,255,255,0.75)', textAlign: 'center', fontWeight: '500' },
lyrEmpty: { color: 'rgba(255,255,255,0.38)', fontSize: 15, textAlign: 'center', marginTop: 60, fontWeight: '500' },
});