Add files via upload
This commit is contained in:
parent
81ce3b2271
commit
9fb037a648
13 changed files with 832 additions and 518 deletions
|
|
@ -1,24 +1,24 @@
|
|||
import { useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue, useAnimatedStyle, withSpring, FadeInDown, FadeOutDown,
|
||||
useAnimatedGestureHandler, runOnJS,
|
||||
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 Svg, { Path } from 'react-native-svg';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Colors, Font, Radius, Spacing, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../constants/theme';
|
||||
import { usePlayer } from '../store';
|
||||
import Svg, { Path, Rect } from 'react-native-svg';
|
||||
import { Colors, Font, Radius, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme';
|
||||
import { usePlayer, useLikes } from '../store';
|
||||
import { useAudio } from '../hooks/useAudio';
|
||||
import { coverUrl } from '../services/api';
|
||||
|
||||
export default function MiniPlayer() {
|
||||
const { queue, index, playing, expanded, setExpanded } = usePlayer();
|
||||
const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer();
|
||||
const { togglePlay } = useAudio();
|
||||
const track = queue[index];
|
||||
const insets = useSafeAreaInsets();
|
||||
const { toggleLike, isLiked } = useLikes();
|
||||
const track = queue[index];
|
||||
|
||||
const translateY = useSharedValue(0);
|
||||
const opacity = useSharedValue(1);
|
||||
|
|
@ -27,13 +27,11 @@ export default function MiniPlayer() {
|
|||
onActive: (e) => {
|
||||
if (e.translationY < 0) {
|
||||
translateY.value = Math.max(e.translationY, -80);
|
||||
opacity.value = 1 + e.translationY / 80;
|
||||
opacity.value = 1 + e.translationY / 80;
|
||||
}
|
||||
},
|
||||
onEnd: (e) => {
|
||||
if (e.translationY < -40 || e.velocityY < -400) {
|
||||
runOnJS(openPlayer)();
|
||||
}
|
||||
if (e.translationY < -40 || e.velocityY < -400) runOnJS(openPlayer)();
|
||||
translateY.value = withSpring(0);
|
||||
opacity.value = withSpring(1);
|
||||
},
|
||||
|
|
@ -54,88 +52,123 @@ export default function MiniPlayer() {
|
|||
togglePlay();
|
||||
}, [togglePlay]);
|
||||
|
||||
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 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={[styles.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
|
||||
style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
|
||||
entering={FadeInDown.springify()}
|
||||
exiting={FadeOutDown.springify()}
|
||||
>
|
||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.border} />
|
||||
{/* Glass background */}
|
||||
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<View style={[StyleSheet.absoluteFill, s.bgTint]} />
|
||||
|
||||
<Pressable style={styles.row} onPress={openPlayer} android_ripple={null}>
|
||||
{/* Progress line at top */}
|
||||
<View style={s.progressBg}>
|
||||
<View style={[s.progressFill, { width: `${pct}%` as any }]} />
|
||||
</View>
|
||||
|
||||
{/* Content row */}
|
||||
<Pressable style={s.row} onPress={openPlayer} android_ripple={null}>
|
||||
{/* Cover */}
|
||||
<Image
|
||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||
style={styles.cover}
|
||||
style={s.cover}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.title} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||
<View style={s.info}>
|
||||
<Text style={s.title} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||
</View>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<Pressable
|
||||
style={styles.btn}
|
||||
onPress={onPlayPress}
|
||||
hitSlop={12}
|
||||
>
|
||||
{playing ? <PauseIcon /> : <PlayIcon />}
|
||||
</Pressable>
|
||||
{/* Controls */}
|
||||
<View style={s.controls}>
|
||||
{/* Like */}
|
||||
<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>
|
||||
|
||||
{/* Next */}
|
||||
<Pressable
|
||||
style={styles.btn}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
const next = usePlayer.getState().nextTrack();
|
||||
usePlayer.getState().setIndex(next);
|
||||
}}
|
||||
hitSlop={12}
|
||||
>
|
||||
<NextIcon />
|
||||
</Pressable>
|
||||
{/* Prev */}
|
||||
<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>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<Pressable
|
||||
onPress={(e) => { e.stopPropagation(); onPlayPress(); }}
|
||||
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>
|
||||
|
||||
{/* Next */}
|
||||
<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>
|
||||
|
||||
{/* Progress line */}
|
||||
<ProgressLine />
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressLine() {
|
||||
const { progress, duration } = usePlayer();
|
||||
const pct = duration > 0 ? (progress / duration) * 100 : 0;
|
||||
return (
|
||||
<View style={styles.progressBg}>
|
||||
<View style={[styles.progressFill, { width: `${pct}%` as any }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon() { return <Svg width={20} height={20} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>; }
|
||||
function PauseIcon() { return <Svg width={20} height={20} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg>; }
|
||||
function NextIcon() { return <Svg width={20} height={20} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg>; }
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden' },
|
||||
border: { position: 'absolute', top: 0, left: 0, right: 0, height: StyleSheet.hairlineWidth, backgroundColor: Colors.border },
|
||||
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.md, gap: Spacing.md },
|
||||
cover: { width: 42, height: 42, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||
info: { flex: 1 },
|
||||
title: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
||||
const s = StyleSheet.create({
|
||||
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderRadius: '14px 14px 0 0' as any, 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 },
|
||||
btn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||
progressBg: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, backgroundColor: Colors.border },
|
||||
progressFill:{ height: 2, backgroundColor: Colors.green },
|
||||
controls: { flexDirection: 'row', alignItems: 'center' },
|
||||
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,65 +1,100 @@
|
|||
import { useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Dimensions } from 'react-native';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, Dimensions, ScrollView,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue, useAnimatedStyle, withSpring,
|
||||
interpolate, useAnimatedGestureHandler, runOnJS, FadeIn,
|
||||
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 } from 'react-native-svg';
|
||||
import Svg, { Path, Polyline, Line, Rect } from 'react-native-svg';
|
||||
import { Colors, Font, Radius, Spacing } from '../constants/theme';
|
||||
import { usePlayer } from '../store';
|
||||
import { usePlayer, useLikes } from '../store';
|
||||
import { useAudio } from '../hooks/useAudio';
|
||||
import { coverUrl } from '../services/api';
|
||||
import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const COVER_SIZE = width - 64;
|
||||
const COVER = width - 48;
|
||||
|
||||
export default function Player() {
|
||||
const {
|
||||
queue, index, playing, progress, duration,
|
||||
repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle,
|
||||
} = usePlayer();
|
||||
const { togglePlay, seek } = useAudio();
|
||||
const { togglePlay, seek } = useAudio();
|
||||
const { toggleLike, isLiked } = useLikes();
|
||||
const insets = useSafeAreaInsets();
|
||||
const track = queue[index];
|
||||
|
||||
const translateY = useSharedValue(0);
|
||||
const coverScale = useSharedValue(1);
|
||||
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);
|
||||
const lyrSheetY = useSharedValue(500);
|
||||
|
||||
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]);
|
||||
|
||||
// Auto-scroll lyrics to current line
|
||||
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((s, h) => s + 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 > 120 || e.velocityY > 800) {
|
||||
runOnJS(close)();
|
||||
} else {
|
||||
translateY.value = withSpring(0, { damping: 20 });
|
||||
}
|
||||
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 overlayStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(translateY.value, [0, 300], [1, 0]),
|
||||
}));
|
||||
|
||||
const close = useCallback(() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setExpanded(false);
|
||||
|
|
@ -85,194 +120,221 @@ export default function Player() {
|
|||
|
||||
if (!track) return null;
|
||||
|
||||
const cover = coverUrl(track.cover_url);
|
||||
const pct = duration > 0 ? progress / duration : 0;
|
||||
const cover = coverUrl(track.cover_url);
|
||||
const pct = duration > 0 ? progress / duration : 0;
|
||||
const liked = isLiked(track.id);
|
||||
|
||||
return (
|
||||
<Animated.View style={[StyleSheet.absoluteFill, styles.root, overlayStyle]} entering={FadeIn.duration(200)}>
|
||||
{/* Blurred background art */}
|
||||
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}>
|
||||
{/* Blurred album art background */}
|
||||
<Image
|
||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||
style={StyleSheet.absoluteFill}
|
||||
contentFit="cover"
|
||||
blurRadius={60}
|
||||
blurRadius={80}
|
||||
/>
|
||||
<View style={[StyleSheet.absoluteFill, styles.dim]} />
|
||||
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
|
||||
<BlurView intensity={20} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
|
||||
<PanGestureHandler onGestureEvent={gestureHandler}>
|
||||
<Animated.View style={[
|
||||
styles.sheet, containerStyle,
|
||||
{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 24 },
|
||||
]}>
|
||||
{/* Handle */}
|
||||
<Pressable onPress={close} hitSlop={20} style={styles.handleWrap}>
|
||||
<View style={styles.handle} />
|
||||
<Animated.View style={[s.sheet, containerStyle, {
|
||||
paddingTop: insets.top + 8,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}]}>
|
||||
|
||||
{/* Drag handle + top label */}
|
||||
<Pressable onPress={close} hitSlop={20} style={s.topBar}>
|
||||
<View style={s.handle} />
|
||||
<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>
|
||||
</Pressable>
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Сейчас играет</Text>
|
||||
</View>
|
||||
|
||||
{/* Cover */}
|
||||
<View style={styles.coverWrap}>
|
||||
<Animated.View style={[styles.coverShadow, coverStyle]}>
|
||||
<View style={s.coverWrap}>
|
||||
<Animated.View style={[s.coverShadow, coverStyle]}>
|
||||
<Image
|
||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||
style={styles.cover}
|
||||
style={s.cover}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Info + like */}
|
||||
<View style={styles.infoRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.trackTitle} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||
{/* Meta row */}
|
||||
<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>
|
||||
<LikeButton />
|
||||
<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>
|
||||
|
||||
{/* Progress bar */}
|
||||
<View style={styles.progressWrap}>
|
||||
{/* Seek bar */}
|
||||
<View style={s.seekWrap}>
|
||||
<Slider
|
||||
style={{ width: '100%', height: 36 }}
|
||||
style={{ width: '100%', height: 32 }}
|
||||
minimumValue={0}
|
||||
maximumValue={1}
|
||||
value={pct}
|
||||
minimumTrackTintColor={Colors.text}
|
||||
maximumTrackTintColor={Colors.text3}
|
||||
maximumTrackTintColor="rgba(255,255,255,0.2)"
|
||||
thumbTintColor={Colors.text}
|
||||
onSlidingComplete={(v) => seek(v * duration)}
|
||||
/>
|
||||
<View style={styles.timeRow}>
|
||||
<Text style={styles.timeText}>{fmt(progress)}</Text>
|
||||
<Text style={styles.timeText}>{fmt(duration)}</Text>
|
||||
<View style={s.timeRow}>
|
||||
<Text style={s.timeText}>{fmt(progress)}</Text>
|
||||
<Text style={s.timeText}>{fmt(duration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Controls */}
|
||||
<View style={styles.controls}>
|
||||
<View style={s.controls}>
|
||||
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
|
||||
<ShuffleIcon active={shuffle} />
|
||||
</CtlBtn>
|
||||
|
||||
<CtlBtn onPress={onPrev}>
|
||||
<PrevIcon />
|
||||
<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={styles.playBtn} onPress={onPlay}>
|
||||
{playing ? <PauseIcon /> : <PlayIcon />}
|
||||
<Pressable style={s.playBtn} onPress={onPlay}>
|
||||
{playing
|
||||
? <Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.bg}><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" fill={Colors.bg} /></Svg>
|
||||
}
|
||||
</Pressable>
|
||||
|
||||
<CtlBtn onPress={onNext}>
|
||||
<NextIcon />
|
||||
<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>
|
||||
|
||||
{/* Bottom row: lyrics + speed */}
|
||||
<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 bottom sheet */}
|
||||
{lyricsOpen && (
|
||||
<View style={s.lyrSheet}>
|
||||
<Pressable style={s.lyrBg} onPress={() => setLyricsOpen(false)} />
|
||||
<BlurView intensity={40} tint="dark" style={s.lyrInner}>
|
||||
{/* Handle */}
|
||||
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
|
||||
<View style={s.lyrHandle} />
|
||||
</Pressable>
|
||||
{/* Mini header */}
|
||||
<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>
|
||||
{/* Lyrics content */}
|
||||
<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 scale = useSharedValue(1);
|
||||
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||
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={style}>
|
||||
<Animated.View style={st}>
|
||||
<Pressable
|
||||
style={[styles.ctlBtn, active && styles.ctlBtnActive]}
|
||||
onPress={onPress}
|
||||
onPressIn={() => { scale.value = withSpring(0.82, { damping: 12 }); }}
|
||||
onPressOut={() => { scale.value = withSpring(1, { damping: 12 }); }}
|
||||
onPressIn={() => { sc.value = withSpring(0.82, { damping: 12 }); }}
|
||||
onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }}
|
||||
hitSlop={12}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
style={[s.ctlBtn, active && s.ctlBtnActive]}
|
||||
>{children}</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function LikeButton() {
|
||||
const scale = useSharedValue(1);
|
||||
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||
return (
|
||||
<Animated.View style={style}>
|
||||
<Pressable
|
||||
hitSlop={12}
|
||||
onPress={() => {
|
||||
scale.value = withSpring(1.35, { damping: 7 }, () => {
|
||||
scale.value = withSpring(1, { damping: 12 });
|
||||
});
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24" fill="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={Colors.text2} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
return <Svg width={28} height={28} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M8 5v14l11-7z" /></Svg>;
|
||||
}
|
||||
function PauseIcon() {
|
||||
return <Svg width={28} height={28} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg>;
|
||||
}
|
||||
function PrevIcon() {
|
||||
return (
|
||||
<Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.text}>
|
||||
<Path d="M19 20L9 12l10-8v16z" />
|
||||
<Path d="M5 19V5h2v14H5z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
function NextIcon() {
|
||||
return (
|
||||
<Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.text}>
|
||||
<Path d="M5 4l10 8-10 8V4z" />
|
||||
<Path d="M19 5v14h-2V5h2z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
function ShuffleIcon({ active }: { active: boolean }) {
|
||||
const c = active ? Colors.green : Colors.text2;
|
||||
const c = active ? Colors.accent : 'rgba(255,255,255,0.5)';
|
||||
return (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M4 20L21 3" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="21,16 21,21 16,21" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<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.green : Colors.text2;
|
||||
const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.5)';
|
||||
return (
|
||||
<View>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M3 11V9a4 4 0 0 1 4-4h14" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<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={styles.repeatBadge}>
|
||||
<Text style={styles.repeatBadgeText}>1</Text>
|
||||
</View>
|
||||
<View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
|
@ -283,27 +345,46 @@ function fmt(s: number) {
|
|||
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { zIndex: 999 },
|
||||
dim: { backgroundColor: 'rgba(0,0,0,0.6)' },
|
||||
sheet: { flex: 1, paddingHorizontal: Spacing.xl },
|
||||
handleWrap: { alignItems: 'center', paddingBottom: 4 },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)' },
|
||||
header: { alignItems: 'center', marginBottom: Spacing.lg },
|
||||
headerTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600', letterSpacing: 0.5 },
|
||||
coverWrap: { alignItems: 'center', marginBottom: Spacing.xl },
|
||||
coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.6, shadowRadius: 32 },
|
||||
cover: { width: COVER_SIZE, height: COVER_SIZE, borderRadius: Radius.xl, backgroundColor: Colors.elevated },
|
||||
infoRow: { flexDirection: 'row', alignItems: 'center', marginBottom: Spacing.md, gap: Spacing.md },
|
||||
trackTitle: { color: Colors.text, fontSize: Font.xl, fontWeight: '700', letterSpacing: -0.3 },
|
||||
artist: { color: 'rgba(255,255,255,0.5)', fontSize: Font.md, marginTop: 4 },
|
||||
progressWrap: { marginBottom: Spacing.lg },
|
||||
timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -6 },
|
||||
timeText: { color: 'rgba(255,255,255,0.35)', fontSize: Font.xs },
|
||||
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4 },
|
||||
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full },
|
||||
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
|
||||
playBtn: { width: 72, height: 72, borderRadius: 36, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center', shadowColor: '#fff', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 12 },
|
||||
repeatBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.green, alignItems: 'center', justifyContent: 'center' },
|
||||
repeatBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' },
|
||||
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 },
|
||||
handle: { display: 'none' },
|
||||
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', gap: 20 },
|
||||
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' },
|
||||
// Lyrics sheet
|
||||
lyrSheet: { position: 'absolute', inset: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' },
|
||||
lyrBg: { ...StyleSheet.absoluteFillObject },
|
||||
lyrInner: { height: '70%', borderRadius: 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 },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue