Add files via upload
This commit is contained in:
parent
81ce3b2271
commit
9fb037a648
13 changed files with 832 additions and 518 deletions
|
|
@ -4,23 +4,26 @@ import { Tabs, router } from 'expo-router';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
|
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
|
||||||
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
|
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
|
||||||
import { useAuth, usePlayer } from '../../store';
|
import { useAuth, usePlayer, useLikes } from '../../store';
|
||||||
import MiniPlayer from '../../components/MiniPlayer';
|
import MiniPlayer from '../../components/MiniPlayer';
|
||||||
import Player from '../../components/Player';
|
import Player from '../../components/Player';
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
const { expanded } = usePlayer();
|
const { expanded } = usePlayer();
|
||||||
|
const { loadLikes } = useLikes();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ready && !user) {
|
if (ready && !user) router.replace('/auth');
|
||||||
router.replace('/auth');
|
|
||||||
}
|
|
||||||
}, [user, ready]);
|
}, [user, ready]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) loadLikes();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
if (!ready) return (
|
if (!ready) return (
|
||||||
<View style={{ flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<ActivityIndicator color="#1db954" />
|
<ActivityIndicator color={Colors.accent} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
@ -32,18 +35,19 @@ export default function TabsLayout() {
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarStyle: styles.tabBar,
|
tabBarStyle: styles.tabBar,
|
||||||
tabBarBackground: () => (
|
tabBarBackground: () => (
|
||||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
),
|
),
|
||||||
tabBarActiveTintColor: Colors.green,
|
tabBarActiveTintColor: Colors.text,
|
||||||
tabBarInactiveTintColor: Colors.text3,
|
tabBarInactiveTintColor: Colors.text3,
|
||||||
tabBarLabelStyle: { fontSize: 10, fontWeight: '600', marginBottom: 4 },
|
tabBarLabelStyle: { fontSize: 9, fontWeight: '500', letterSpacing: 0.3, marginBottom: 4 },
|
||||||
tabBarItemStyle: { paddingTop: 8 },
|
tabBarItemStyle: { paddingTop: 6 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tabs.Screen name="index" options={{ title: 'Главная', tabBarIcon: ({ color }) => <HomeIcon color={color} /> }} />
|
<Tabs.Screen name="index" options={{ title: 'Главная', tabBarIcon: ({ color }) => <HomeIcon color={color} /> }} />
|
||||||
<Tabs.Screen name="library" options={{ title: 'Библиотека', tabBarIcon: ({ color }) => <LibIcon color={color} /> }} />
|
<Tabs.Screen name="library" options={{ title: 'Библиотека', tabBarIcon: ({ color }) => <LibIcon color={color} /> }} />
|
||||||
<Tabs.Screen name="search" options={{ title: 'Поиск', tabBarIcon: ({ color }) => <SearchIcon color={color} /> }} />
|
<Tabs.Screen name="search" options={{ title: 'Поиск', tabBarIcon: ({ color }) => <SearchIcon color={color} /> }} />
|
||||||
<Tabs.Screen name="profile" options={{ title: 'Профиль', tabBarIcon: ({ color }) => <ProfileIcon color={color} /> }} />
|
<Tabs.Screen name="liked" options={{ title: 'Понравилось', tabBarIcon: ({ color }) => <HeartIcon color={color} /> }} />
|
||||||
|
<Tabs.Screen name="profile" options={{ title: 'Профиль', tabBarIcon: ({ color }) => <ProfileIcon color={color} /> }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<MiniPlayer />
|
<MiniPlayer />
|
||||||
|
|
@ -54,35 +58,39 @@ export default function TabsLayout() {
|
||||||
|
|
||||||
function HomeIcon({ color }: { color: string }) {
|
function HomeIcon({ color }: { color: string }) {
|
||||||
return (
|
return (
|
||||||
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
<Path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
<Path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<Polyline points="9,22 9,12 15,12 15,22" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
<Polyline points="9,22 9,12 15,12 15,22" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LibIcon({ color }: { color: string }) {
|
function LibIcon({ color }: { color: string }) {
|
||||||
return (
|
return (
|
||||||
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
<Path d="M9 18V5l12-2v13" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
|
<Path d="M9 18V5l12-2v13" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
<Circle cx={6} cy={18} r={3} stroke={color} strokeWidth={1.8} />
|
<Circle cx={6} cy={18} r={3} stroke={color} strokeWidth={1.8} />
|
||||||
<Circle cx={18} cy={16} r={3} stroke={color} strokeWidth={1.8} />
|
<Circle cx={18} cy={16} r={3} stroke={color} strokeWidth={1.8} />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchIcon({ color }: { color: string }) {
|
function SearchIcon({ color }: { color: string }) {
|
||||||
return (
|
return (
|
||||||
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
<Circle cx={11} cy={11} r={8} stroke={color} strokeWidth={1.8} />
|
<Circle cx={11} cy={11} r={8} stroke={color} strokeWidth={1.8} />
|
||||||
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
|
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function HeartIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<Svg width={20} height={20} 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={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
function ProfileIcon({ color }: { color: string }) {
|
function ProfileIcon({ color }: { color: string }) {
|
||||||
return (
|
return (
|
||||||
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
<Path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
|
<Path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
<Circle cx={12} cy={7} r={4} stroke={color} strokeWidth={1.8} />
|
<Circle cx={12} cy={7} r={4} stroke={color} strokeWidth={1.8} />
|
||||||
</Svg>
|
</Svg>
|
||||||
|
|
@ -92,7 +100,8 @@ function ProfileIcon({ color }: { color: string }) {
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
tabBar: {
|
tabBar: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
borderTopWidth: 0,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: 'rgba(255,255,255,0.08)',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
height: TAB_BAR_HEIGHT,
|
height: TAB_BAR_HEIGHT,
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,24 @@
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl } from 'react-native';
|
||||||
View, Text, ScrollView, StyleSheet, Pressable,
|
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||||
Dimensions, RefreshControl,
|
|
||||||
} from 'react-native';
|
|
||||||
import Animated, {
|
|
||||||
FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring,
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||||
import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
|
import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
|
||||||
import { usePlayer } from '../../store';
|
import { usePlayer, useLikes } from '../../store';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const CARD_W = width * 0.42;
|
const CARD_W = (width - Spacing.md * 3) / 2;
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const [tracks, setTracks] = useState<Track[]>([]);
|
const [tracks, setTracks] = useState<Track[]>([]);
|
||||||
const [albums, setAlbums] = useState<Album[]>([]);
|
const [albums, setAlbums] = useState<Album[]>([]);
|
||||||
const [refresh, setRefresh] = useState(false);
|
const [refresh, setRefresh] = useState(false);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { setQueue, queue } = usePlayer();
|
const { setQueue } = usePlayer();
|
||||||
|
const { toggleLike, isLiked } = useLikes();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [t, a] = await Promise.all([getTracks(), getAlbums()]);
|
const [t, a] = await Promise.all([getTracks(), getAlbums()]);
|
||||||
|
|
@ -36,48 +34,52 @@ export default function HomeScreen() {
|
||||||
setRefresh(false);
|
setRefresh(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const playTrack = (track: Track, index: number) => {
|
const playTrack = (track: Track, list: Track[], idx: number) => {
|
||||||
setQueue(tracks, index);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
setQueue(list, idx);
|
||||||
usePlayer.getState().setExpanded(true);
|
usePlayer.getState().setExpanded(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scroll}
|
style={styles.scroll}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[styles.content, { paddingTop: insets.top + 14, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }]}
|
||||||
styles.content,
|
refreshControl={<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.text2} />}
|
||||||
{ paddingTop: insets.top + Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }
|
|
||||||
]}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.green} />
|
|
||||||
}
|
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Animated.View entering={FadeIn.duration(500)}>
|
<Animated.View entering={FadeIn.duration(400)} style={styles.pageHeader}>
|
||||||
<Text style={styles.greeting}>Добро пожаловать</Text>
|
<Text style={styles.pageTitle}>NodeShift Music</Text>
|
||||||
<Text style={styles.title}>NodeShift Music</Text>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Albums row */}
|
{/* Now playing strip (if track exists) */}
|
||||||
|
<NowPlayingStrip />
|
||||||
|
|
||||||
|
{/* Albums */}
|
||||||
{albums.length > 0 && (
|
{albums.length > 0 && (
|
||||||
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Альбомы</Text>
|
<Text style={styles.secTitle}>Альбомы</Text>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hScroll}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hRow}>
|
||||||
{albums.map((a, i) => (
|
{albums.map((a, i) => (
|
||||||
<AlbumCard key={a.id} album={a} index={i}
|
<AlbumCard key={a.id} album={a} delay={i * 50} />
|
||||||
onPress={() => {/* open album */}} />
|
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tracks list */}
|
{/* Tracks */}
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
|
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Треки</Text>
|
<Text style={styles.secTitle}>Треки</Text>
|
||||||
{tracks.map((t, i) => (
|
{tracks.map((t, i) => (
|
||||||
<TrackRow key={t.id} track={t} index={i} onPress={() => playTrack(t, i)} />
|
<TrackRow
|
||||||
|
key={t.id}
|
||||||
|
track={t}
|
||||||
|
index={i}
|
||||||
|
liked={isLiked(t.id)}
|
||||||
|
onPress={() => playTrack(t, tracks, i)}
|
||||||
|
onLike={() => toggleLike(t.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -85,17 +87,45 @@ export default function HomeScreen() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlbumCard({ album, index, onPress }: { album: Album; index: number; onPress: () => void }) {
|
function NowPlayingStrip() {
|
||||||
|
const { queue, index, playing, setExpanded } = usePlayer();
|
||||||
|
const track = queue[index];
|
||||||
|
if (!track) return null;
|
||||||
|
const cover = coverUrl(track.cover_url);
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.duration(300)} style={styles.npStrip}>
|
||||||
|
{cover && <Image source={{ uri: cover }} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={40} />}
|
||||||
|
<View style={[StyleSheet.absoluteFill, styles.npDim]} />
|
||||||
|
<Pressable style={styles.npContent} onPress={() => setExpanded(true)}>
|
||||||
|
{cover
|
||||||
|
? <Image source={{ uri: cover }} style={styles.npCover} contentFit="cover" />
|
||||||
|
: <View style={[styles.npCover, { backgroundColor: Colors.elevated }]} />
|
||||||
|
}
|
||||||
|
<View style={styles.npInfo}>
|
||||||
|
<Text style={styles.npLabel}>Сейчас играет</Text>
|
||||||
|
<Text style={styles.npTitle} numberOfLines={1}>{track.title}</Text>
|
||||||
|
<Text style={styles.npArtist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.npDot}>
|
||||||
|
{playing
|
||||||
|
? <View style={styles.npEq}>{[1,2,3].map(i => <Animated.View key={i} style={[styles.npBar, { height: 4 + i * 4 }]} />)}</View>
|
||||||
|
: <Svg width={16} height={16} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumCard({ album, delay }: { album: Album; delay: number }) {
|
||||||
const scale = useSharedValue(1);
|
const scale = useSharedValue(1);
|
||||||
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
const cover = coverUrl(album.cover_url);
|
const cover = coverUrl(album.cover_url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInDown.delay(index * 60)} style={style}>
|
<Animated.View entering={FadeInDown.delay(delay)} style={style}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
|
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
|
||||||
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||||
onPress={onPress}
|
|
||||||
style={styles.albumCard}
|
style={styles.albumCard}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -111,62 +141,71 @@ function AlbumCard({ album, index, onPress }: { album: Album; index: number; onP
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackRow({ track, index, onPress }: { track: Track; index: number; onPress: () => void }) {
|
function TrackRow({ track, index, liked, onPress, onLike }: {
|
||||||
|
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
|
||||||
|
}) {
|
||||||
const { queue, index: qi } = usePlayer();
|
const { queue, index: qi } = usePlayer();
|
||||||
const isPlaying = queue[qi]?.id === track.id;
|
const active = queue[qi]?.id === track.id;
|
||||||
const scale = useSharedValue(1);
|
const cover = coverUrl(track.cover_url);
|
||||||
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
|
||||||
const cover = coverUrl(track.cover_url);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInDown.delay(index * 40).duration(300)} style={style}>
|
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
|
||||||
<Pressable
|
<Text style={styles.trackNum}>{index + 1}</Text>
|
||||||
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
|
<Image
|
||||||
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||||
onPress={onPress}
|
style={styles.trackCover}
|
||||||
style={[styles.trackRow, isPlaying && styles.trackRowActive]}
|
contentFit="cover"
|
||||||
>
|
transition={200}
|
||||||
<Image
|
/>
|
||||||
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
<View style={styles.trackInfo}>
|
||||||
style={styles.trackCover}
|
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
||||||
contentFit="cover"
|
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
|
||||||
transition={200}
|
</View>
|
||||||
/>
|
<Pressable onPress={onLike} hitSlop={12}>
|
||||||
<View style={styles.trackInfo}>
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
||||||
<Text style={[styles.trackTitle, isPlaying && { color: Colors.green }]} numberOfLines={1}>
|
<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.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
{track.title}
|
</Svg>
|
||||||
</Text>
|
|
||||||
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
|
||||||
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(s: number) {
|
function fmt(s: number) {
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
||||||
const sec = Math.floor(s % 60);
|
|
||||||
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
scroll: { flex: 1, backgroundColor: Colors.bg },
|
scroll: { flex: 1, backgroundColor: Colors.bg },
|
||||||
content: { paddingHorizontal: Spacing.md },
|
content: { paddingHorizontal: Spacing.md },
|
||||||
greeting: { color: Colors.text3, fontSize: Font.sm, fontWeight: '600', marginBottom: 4 },
|
pageHeader: { marginBottom: Spacing.md },
|
||||||
title: { color: Colors.text, fontSize: Font.xxxl, fontWeight: '800', letterSpacing: -1, marginBottom: Spacing.xl },
|
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
|
||||||
section: { marginBottom: Spacing.xl },
|
section: { marginBottom: Spacing.xl },
|
||||||
sectionTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
|
secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
|
||||||
hScroll: { paddingRight: Spacing.md, gap: Spacing.md },
|
hRow: { gap: Spacing.md, paddingRight: Spacing.md },
|
||||||
albumCard: { width: CARD_W },
|
albumCard: { width: CARD_W },
|
||||||
albumCover: { width: CARD_W, height: CARD_W, borderRadius: Radius.md, marginBottom: Spacing.sm, backgroundColor: Colors.elevated },
|
albumCover: { width: CARD_W, height: CARD_W, borderRadius: Radius.md, backgroundColor: Colors.elevated, marginBottom: Spacing.sm },
|
||||||
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
||||||
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
||||||
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: Spacing.sm, borderRadius: Radius.md, gap: Spacing.md },
|
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: Spacing.sm },
|
||||||
trackRowActive: { backgroundColor: Colors.surface },
|
trackRowActive: { backgroundColor: Colors.surface },
|
||||||
trackCover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||||
trackInfo: { flex: 1 },
|
trackCover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||||
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
trackInfo: { flex: 1 },
|
||||||
trackArtist:{ color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
trackDur: { color: Colors.text3, fontSize: Font.xs },
|
trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||||
|
trackDur: { color: Colors.text3, fontSize: Font.xs },
|
||||||
|
// Now playing strip
|
||||||
|
npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 },
|
||||||
|
npDim: { backgroundColor: 'rgba(0,0,0,0.55)' },
|
||||||
|
npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
||||||
|
npCover: { width: 52, height: 52, borderRadius: Radius.sm },
|
||||||
|
npInfo: { flex: 1 },
|
||||||
|
npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
|
||||||
|
npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
|
npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 },
|
||||||
|
npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 },
|
||||||
|
npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import { View, Text, FlatList, StyleSheet, TextInput, Pressable } from 'react-native';
|
||||||
View, Text, FlatList, StyleSheet, TextInput, Pressable,
|
|
||||||
} from 'react-native';
|
|
||||||
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import Svg, { Circle, Line } from 'react-native-svg';
|
import Svg, { Circle, Line, Path } from 'react-native-svg';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||||
import { getTracks, Track, coverUrl } from '../../services/api';
|
import { getTracks, Track, coverUrl } from '../../services/api';
|
||||||
import { usePlayer } from '../../store';
|
import { usePlayer, useLikes } from '../../store';
|
||||||
|
|
||||||
export default function LibraryScreen() {
|
export default function LibraryScreen() {
|
||||||
const [all, setAll] = useState<Track[]>([]);
|
const [all, setAll] = useState<Track[]>([]);
|
||||||
|
|
@ -16,6 +15,7 @@ export default function LibraryScreen() {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { setQueue } = usePlayer();
|
const { setQueue } = usePlayer();
|
||||||
|
const { toggleLike, isLiked } = useLikes();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTracks().then(t => { setAll(t); setFiltered(t); });
|
getTracks().then(t => { setAll(t); setFiltered(t); });
|
||||||
|
|
@ -28,13 +28,15 @@ export default function LibraryScreen() {
|
||||||
) : all);
|
) : all);
|
||||||
}, [query, all]);
|
}, [query, all]);
|
||||||
|
|
||||||
const playTrack = (track: Track, index: number) => {
|
const playTrack = (idx: number) => {
|
||||||
setQueue(filtered, index);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
setQueue(filtered, idx);
|
||||||
usePlayer.getState().setExpanded(true);
|
usePlayer.getState().setExpanded(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||||
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>Библиотека</Text>
|
<Text style={styles.title}>Библиотека</Text>
|
||||||
<Text style={styles.count}>{all.length} треков</Text>
|
<Text style={styles.count}>{all.length} треков</Text>
|
||||||
|
|
@ -42,7 +44,10 @@ export default function LibraryScreen() {
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<View style={styles.searchWrap}>
|
<View style={styles.searchWrap}>
|
||||||
<SearchSvg />
|
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none" style={{ marginLeft: 12 }}>
|
||||||
|
<Circle cx={11} cy={11} r={8} stroke={Colors.text3} strokeWidth={1.8} />
|
||||||
|
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
placeholder="Поиск треков..."
|
placeholder="Поиск треков..."
|
||||||
|
|
@ -53,8 +58,8 @@ export default function LibraryScreen() {
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
{!!query && (
|
{!!query && (
|
||||||
<Pressable onPress={() => setQuery('')} hitSlop={12}>
|
<Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}>
|
||||||
<Text style={styles.clearBtn}>✕</Text>
|
<Text style={{ color: Colors.text3, fontSize: 12 }}>✕</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -62,40 +67,36 @@ export default function LibraryScreen() {
|
||||||
<FlatList
|
<FlatList
|
||||||
data={filtered}
|
data={filtered}
|
||||||
keyExtractor={t => String(t.id)}
|
keyExtractor={t => String(t.id)}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||||
paddingHorizontal: Spacing.md,
|
|
||||||
paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24,
|
|
||||||
}}
|
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<TrackItem track={item} index={index} onPress={() => playTrack(item, index)} />
|
<TrackItem
|
||||||
|
track={item}
|
||||||
|
index={index}
|
||||||
|
liked={isLiked(item.id)}
|
||||||
|
onPress={() => playTrack(index)}
|
||||||
|
onLike={() => toggleLike(item.id)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackItem({ track, index, onPress }: { track: Track; index: number; onPress: () => void }) {
|
function TrackItem({ track, index, liked, onPress, onLike }: {
|
||||||
|
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
|
||||||
|
}) {
|
||||||
const { queue, index: qi } = usePlayer();
|
const { queue, index: qi } = usePlayer();
|
||||||
const active = queue[qi]?.id === track.id;
|
const active = queue[qi]?.id === track.id;
|
||||||
const scale = useSharedValue(1);
|
const cover = coverUrl(track.cover_url);
|
||||||
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
|
||||||
const cover = coverUrl(track.cover_url);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInDown.delay(Math.min(index * 30, 300)).duration(300)} style={aStyle}>
|
<Animated.View entering={FadeInDown.delay(Math.min(index * 25, 300)).duration(300)}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
|
|
||||||
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[styles.row, active && styles.rowActive]}
|
style={[styles.row, active && styles.rowActive]}
|
||||||
>
|
>
|
||||||
<View style={styles.numWrap}>
|
<Text style={styles.num}>{index + 1}</Text>
|
||||||
{active
|
|
||||||
? <View style={styles.eqDots}>{[0,1,2].map(i => <EqDot key={i} delay={i * 150} />)}</View>
|
|
||||||
: <Text style={styles.num}>{index + 1}</Text>}
|
|
||||||
</View>
|
|
||||||
<Image
|
<Image
|
||||||
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||||
style={styles.cover}
|
style={styles.cover}
|
||||||
|
|
@ -103,65 +104,37 @@ function TrackItem({ track, index, onPress }: { track: Track; index: number; onP
|
||||||
transition={200}
|
transition={200}
|
||||||
/>
|
/>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={[styles.trackTitle, active && { color: Colors.green }]} numberOfLines={1}>
|
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
||||||
{track.title}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<Pressable onPress={onLike} hitSlop={12}>
|
||||||
|
<Svg width={16} height={16} 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.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
<Text style={styles.dur}>{fmt(track.duration)}</Text>
|
<Text style={styles.dur}>{fmt(track.duration)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EqDot({ delay }: { delay: number }) {
|
|
||||||
const h = useSharedValue(4);
|
|
||||||
const style = useAnimatedStyle(() => ({ height: h.value }));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loop = () => {
|
|
||||||
h.value = withSpring(12 + Math.random() * 8, { damping: 6 }, () => {
|
|
||||||
h.value = withSpring(4, { damping: 6 }, loop);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const t = setTimeout(loop, delay);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <Animated.View style={[styles.dot, style]} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchSvg() {
|
|
||||||
return (
|
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" style={{ marginLeft: 12 }}>
|
|
||||||
<Circle cx={11} cy={11} r={8} stroke={Colors.text3} strokeWidth={1.8} />
|
|
||||||
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(s: number) {
|
function fmt(s: number) {
|
||||||
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
||||||
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.bg },
|
container: { flex: 1, backgroundColor: Colors.bg },
|
||||||
header: { paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm, flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm },
|
header: { flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm },
|
||||||
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
|
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 },
|
||||||
count: { color: Colors.text3, fontSize: Font.sm },
|
count: { color: Colors.text3, fontSize: Font.sm },
|
||||||
searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: Spacing.sm },
|
searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: 8 },
|
||||||
searchInput: { flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 12, paddingRight: Spacing.md },
|
searchInput:{ flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11, paddingRight: Spacing.md },
|
||||||
clearBtn: { color: Colors.text3, fontSize: Font.sm, paddingRight: 12 },
|
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: Spacing.sm, borderRadius: Radius.sm, paddingHorizontal: 4 },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderRadius: Radius.sm, gap: Spacing.md },
|
rowActive: { backgroundColor: Colors.surface },
|
||||||
rowActive: { backgroundColor: Colors.surface + '80' },
|
num: { width: 22, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||||
numWrap: { width: 28, alignItems: 'center' },
|
cover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||||
num: { color: Colors.text3, fontSize: Font.sm },
|
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
eqDots: { flexDirection: 'row', alignItems: 'flex-end', gap: 2, height: 16 },
|
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||||
dot: { width: 3, backgroundColor: Colors.green, borderRadius: 2 },
|
dur: { color: Colors.text3, fontSize: Font.xs },
|
||||||
cover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
|
||||||
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
|
||||||
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
|
||||||
dur: { color: Colors.text3, fontSize: Font.xs, paddingRight: 4 },
|
|
||||||
sep: { height: 1 },
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
122
app/(tabs)/liked.tsx
Normal file
122
app/(tabs)/liked.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, FlatList, StyleSheet, Pressable } from 'react-native';
|
||||||
|
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||||
|
import { getTracks, Track, coverUrl } from '../../services/api';
|
||||||
|
import { usePlayer, useLikes } from '../../store';
|
||||||
|
|
||||||
|
export default function LikedScreen() {
|
||||||
|
const [all, setAll] = useState<Track[]>([]);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { setQueue } = usePlayer();
|
||||||
|
const { likes, toggleLike } = useLikes();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTracks().then(setAll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const liked = all.filter(t => likes.has(t.id));
|
||||||
|
|
||||||
|
const playTrack = (track: Track, idx: number) => {
|
||||||
|
setQueue(liked, idx);
|
||||||
|
usePlayer.getState().setExpanded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.pink}>
|
||||||
|
<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.pink} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
<Text style={styles.title}>Понравилось</Text>
|
||||||
|
<Text style={styles.count}>{liked.length} треков</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{liked.length === 0 ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyIcon}>♡</Text>
|
||||||
|
<Text style={styles.emptyTitle}>Пусто</Text>
|
||||||
|
<Text style={styles.emptyText}>Нажмите ♡ у трека, чтобы добавить его сюда</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={liked}
|
||||||
|
keyExtractor={t => String(t.id)}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<LikedRow
|
||||||
|
track={item}
|
||||||
|
index={index}
|
||||||
|
onPress={() => playTrack(item, index)}
|
||||||
|
onUnlike={() => toggleLike(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LikedRow({ track, index, onPress, onUnlike }: {
|
||||||
|
track: Track; index: number; onPress: () => void; onUnlike: () => void;
|
||||||
|
}) {
|
||||||
|
const { queue, index: qi } = usePlayer();
|
||||||
|
const active = queue[qi]?.id === track.id;
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
|
const cover = coverUrl(track.cover_url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(Math.min(index * 40, 400)).duration(300)} style={aStyle}>
|
||||||
|
<Pressable
|
||||||
|
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
|
||||||
|
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.row, active && styles.rowActive]}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||||
|
style={styles.cover}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
||||||
|
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={onUnlike} hitSlop={12} style={styles.likeBtn}>
|
||||||
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.pink}>
|
||||||
|
<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.pink} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(s: number) {
|
||||||
|
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
||||||
|
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.bg },
|
||||||
|
header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.md },
|
||||||
|
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 },
|
||||||
|
count: { color: Colors.text3, fontSize: Font.sm },
|
||||||
|
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
|
||||||
|
emptyIcon: { fontSize: 52, color: Colors.text3, marginBottom: 16 },
|
||||||
|
emptyTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: 8 },
|
||||||
|
emptyText: { color: Colors.text2, fontSize: Font.sm, textAlign: 'center', lineHeight: 20 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm },
|
||||||
|
rowActive: { backgroundColor: Colors.surface + '80' },
|
||||||
|
cover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||||
|
info: { flex: 1 },
|
||||||
|
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
|
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||||
|
likeBtn: { padding: 4 },
|
||||||
|
});
|
||||||
|
|
@ -111,9 +111,9 @@ const styles = StyleSheet.create({
|
||||||
displayName: { color: Colors.text, fontSize: Font.xl, fontWeight: '700' },
|
displayName: { color: Colors.text, fontSize: Font.xl, fontWeight: '700' },
|
||||||
email: { color: Colors.text2, fontSize: Font.sm },
|
email: { color: Colors.text2, fontSize: Font.sm },
|
||||||
planBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: Radius.full, backgroundColor: Colors.elevated, marginTop: 4 },
|
planBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: Radius.full, backgroundColor: Colors.elevated, marginTop: 4 },
|
||||||
planBadgePro: { backgroundColor: Colors.green + '22', borderWidth: 1, borderColor: Colors.green + '44' },
|
planBadgePro: { backgroundColor: Colors.accent + '22', borderWidth: 1, borderColor: Colors.accent + '44' },
|
||||||
planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' },
|
planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' },
|
||||||
planTextPro: { color: Colors.green },
|
planTextPro: { color: Colors.accent },
|
||||||
section: { marginBottom: Spacing.lg },
|
section: { marginBottom: Spacing.lg },
|
||||||
sectionLabel: { color: Colors.text3, fontSize: Font.xs, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1, marginBottom: Spacing.sm },
|
sectionLabel: { color: Colors.text3, fontSize: Font.xs, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1, marginBottom: Spacing.sm },
|
||||||
infoRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.border },
|
infoRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.border },
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import {
|
import { View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard } from 'react-native';
|
||||||
View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard,
|
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
|
||||||
} from 'react-native';
|
|
||||||
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||||
import { searchTracks, Track, coverUrl } from '../../services/api';
|
import { searchTracks, Track, coverUrl } from '../../services/api';
|
||||||
import { usePlayer } from '../../store';
|
import { usePlayer, useLikes } from '../../store';
|
||||||
|
|
||||||
export default function SearchScreen() {
|
export default function SearchScreen() {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
|
@ -16,6 +16,7 @@ export default function SearchScreen() {
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { setQueue } = usePlayer();
|
const { setQueue } = usePlayer();
|
||||||
|
const { toggleLike, isLiked } = useLikes();
|
||||||
|
|
||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
if (!q.trim()) { setResults([]); setSearched(false); return; }
|
if (!q.trim()) { setResults([]); setSearched(false); return; }
|
||||||
|
|
@ -28,6 +29,7 @@ export default function SearchScreen() {
|
||||||
|
|
||||||
const playTrack = (index: number) => {
|
const playTrack = (index: number) => {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
setQueue(results, index);
|
setQueue(results, index);
|
||||||
usePlayer.getState().setExpanded(true);
|
usePlayer.getState().setExpanded(true);
|
||||||
};
|
};
|
||||||
|
|
@ -52,14 +54,12 @@ export default function SearchScreen() {
|
||||||
|
|
||||||
{!searched && (
|
{!searched && (
|
||||||
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
||||||
<Text style={styles.emptyIcon}>🎵</Text>
|
<Text style={styles.emptyText}>Введите название трека или исполнителя</Text>
|
||||||
<Text style={styles.emptyText}>Введите название трека{'\n'}или имя исполнителя</Text>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{searched && !loading && results.length === 0 && (
|
{searched && !loading && results.length === 0 && (
|
||||||
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
||||||
<Text style={styles.emptyIcon}>🔍</Text>
|
|
||||||
<Text style={styles.emptyText}>Ничего не найдено</Text>
|
<Text style={styles.emptyText}>Ничего не найдено</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -68,69 +68,63 @@ export default function SearchScreen() {
|
||||||
data={results}
|
data={results}
|
||||||
keyExtractor={t => String(t.id)}
|
keyExtractor={t => String(t.id)}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||||
paddingHorizontal: Spacing.md,
|
|
||||||
paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24,
|
|
||||||
}}
|
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<SearchResult track={item} index={index} onPress={() => playTrack(index)} />
|
<Animated.View entering={FadeInDown.delay(index * 30).duration(250)}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => playTrack(index)}
|
||||||
|
style={[styles.row, usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id && styles.rowActive]}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const cover = coverUrl(item.cover_url);
|
||||||
|
const liked = isLiked(item.id);
|
||||||
|
const active = usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||||
|
style={styles.cover}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{item.title}</Text>
|
||||||
|
<Text style={styles.artist} numberOfLines={1}>{item.artist}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => toggleLike(item.id)} hitSlop={12}>
|
||||||
|
<Svg width={16} height={16} 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.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.dur}>{fmt(item.duration)}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchResult({ track, index, onPress }: { track: Track; index: number; onPress: () => void }) {
|
|
||||||
const { queue, index: qi } = usePlayer();
|
|
||||||
const active = queue[qi]?.id === track.id;
|
|
||||||
const scale = useSharedValue(1);
|
|
||||||
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
|
||||||
const cover = coverUrl(track.cover_url);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View entering={FadeInDown.delay(index * 40).duration(250)} style={aStyle}>
|
|
||||||
<Pressable
|
|
||||||
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
|
|
||||||
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
|
||||||
onPress={onPress}
|
|
||||||
style={[styles.row, active && styles.rowActive]}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
|
||||||
style={styles.cover}
|
|
||||||
contentFit="cover"
|
|
||||||
transition={200}
|
|
||||||
/>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={[styles.trackTitle, active && { color: Colors.green }]} numberOfLines={1}>
|
|
||||||
{track.title}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.dur}>{fmt(track.duration)}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(s: number) {
|
function fmt(s: number) {
|
||||||
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
||||||
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.bg },
|
container: { flex: 1, backgroundColor: Colors.bg },
|
||||||
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, marginBottom: Spacing.md },
|
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, marginBottom: Spacing.md },
|
||||||
inputWrap: { marginHorizontal: Spacing.md, marginBottom: Spacing.md, backgroundColor: Colors.surface, borderRadius: Radius.md, paddingHorizontal: Spacing.md },
|
inputWrap: { marginHorizontal: Spacing.md, marginBottom: Spacing.md, backgroundColor: Colors.surface, borderRadius: Radius.md, paddingHorizontal: Spacing.md },
|
||||||
input: { color: Colors.text, fontSize: Font.md, paddingVertical: 13 },
|
input: { color: Colors.text, fontSize: Font.md, paddingVertical: 12 },
|
||||||
emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
|
emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
|
||||||
emptyIcon: { fontSize: 48, marginBottom: Spacing.md },
|
emptyText: { color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||||
emptyText: { color: Colors.text2, fontSize: Font.md, textAlign: 'center', lineHeight: 24 },
|
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm },
|
rowActive: { backgroundColor: Colors.surface },
|
||||||
rowActive: { backgroundColor: Colors.surface + '80' },
|
cover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||||
cover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
trackTitle:{ color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||||
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
dur: { color: Colors.text3, fontSize: Font.xs },
|
||||||
dur: { color: Colors.text3, fontSize: Font.xs },
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ function useOTAUpdates() {
|
||||||
await Updates.fetchUpdateAsync();
|
await Updates.fetchUpdateAsync();
|
||||||
await Updates.reloadAsync();
|
await Updates.reloadAsync();
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
} catch {
|
} catch {
|
||||||
// silent fail
|
// silent fail
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue, useAnimatedStyle, withSpring, FadeInDown, FadeOutDown,
|
useSharedValue, useAnimatedStyle, withSpring,
|
||||||
useAnimatedGestureHandler, runOnJS,
|
FadeInDown, FadeOutDown, useAnimatedGestureHandler, runOnJS,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { PanGestureHandler } from 'react-native-gesture-handler';
|
import { PanGestureHandler } from 'react-native-gesture-handler';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Colors, Font, Radius, Spacing, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../constants/theme';
|
import Svg, { Path, Rect } from 'react-native-svg';
|
||||||
import { usePlayer } from '../store';
|
import { Colors, Font, Radius, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme';
|
||||||
|
import { usePlayer, useLikes } from '../store';
|
||||||
import { useAudio } from '../hooks/useAudio';
|
import { useAudio } from '../hooks/useAudio';
|
||||||
import { coverUrl } from '../services/api';
|
import { coverUrl } from '../services/api';
|
||||||
|
|
||||||
export default function MiniPlayer() {
|
export default function MiniPlayer() {
|
||||||
const { queue, index, playing, expanded, setExpanded } = usePlayer();
|
const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer();
|
||||||
const { togglePlay } = useAudio();
|
const { togglePlay } = useAudio();
|
||||||
const track = queue[index];
|
const { toggleLike, isLiked } = useLikes();
|
||||||
const insets = useSafeAreaInsets();
|
const track = queue[index];
|
||||||
|
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
|
|
@ -27,13 +27,11 @@ export default function MiniPlayer() {
|
||||||
onActive: (e) => {
|
onActive: (e) => {
|
||||||
if (e.translationY < 0) {
|
if (e.translationY < 0) {
|
||||||
translateY.value = Math.max(e.translationY, -80);
|
translateY.value = Math.max(e.translationY, -80);
|
||||||
opacity.value = 1 + e.translationY / 80;
|
opacity.value = 1 + e.translationY / 80;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEnd: (e) => {
|
onEnd: (e) => {
|
||||||
if (e.translationY < -40 || e.velocityY < -400) {
|
if (e.translationY < -40 || e.velocityY < -400) runOnJS(openPlayer)();
|
||||||
runOnJS(openPlayer)();
|
|
||||||
}
|
|
||||||
translateY.value = withSpring(0);
|
translateY.value = withSpring(0);
|
||||||
opacity.value = withSpring(1);
|
opacity.value = withSpring(1);
|
||||||
},
|
},
|
||||||
|
|
@ -54,88 +52,123 @@ export default function MiniPlayer() {
|
||||||
togglePlay();
|
togglePlay();
|
||||||
}, [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;
|
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 (
|
return (
|
||||||
<PanGestureHandler onGestureEvent={gestureHandler}>
|
<PanGestureHandler onGestureEvent={gestureHandler}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
|
style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
|
||||||
entering={FadeInDown.springify()}
|
entering={FadeInDown.springify()}
|
||||||
exiting={FadeOutDown.springify()}
|
exiting={FadeOutDown.springify()}
|
||||||
>
|
>
|
||||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
{/* Glass background */}
|
||||||
<View style={styles.border} />
|
<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 */}
|
{/* Cover */}
|
||||||
<Image
|
<Image
|
||||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
style={styles.cover}
|
style={s.cover}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
transition={200}
|
transition={200}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<View style={styles.info}>
|
<View style={s.info}>
|
||||||
<Text style={styles.title} numberOfLines={1}>{track.title}</Text>
|
<Text style={s.title} numberOfLines={1}>{track.title}</Text>
|
||||||
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Play/Pause */}
|
{/* Controls */}
|
||||||
<Pressable
|
<View style={s.controls}>
|
||||||
style={styles.btn}
|
{/* Like */}
|
||||||
onPress={onPlayPress}
|
<Pressable
|
||||||
hitSlop={12}
|
onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }}
|
||||||
>
|
hitSlop={10}
|
||||||
{playing ? <PauseIcon /> : <PlayIcon />}
|
style={s.ctlBtn}
|
||||||
</Pressable>
|
>
|
||||||
|
<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 */}
|
{/* Prev */}
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.btn}
|
onPress={(e) => { e.stopPropagation(); onPrev(); }}
|
||||||
onPress={() => {
|
hitSlop={10}
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
style={s.ctlBtn}
|
||||||
const next = usePlayer.getState().nextTrack();
|
>
|
||||||
usePlayer.getState().setIndex(next);
|
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24">
|
||||||
}}
|
<Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
|
||||||
hitSlop={12}
|
</Svg>
|
||||||
>
|
</Pressable>
|
||||||
<NextIcon />
|
|
||||||
</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>
|
</Pressable>
|
||||||
|
|
||||||
{/* Progress line */}
|
|
||||||
<ProgressLine />
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</PanGestureHandler>
|
</PanGestureHandler>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressLine() {
|
const s = StyleSheet.create({
|
||||||
const { progress, duration } = usePlayer();
|
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderRadius: '14px 14px 0 0' as any, borderTopLeftRadius: 14, borderTopRightRadius: 14 },
|
||||||
const pct = duration > 0 ? (progress / duration) * 100 : 0;
|
bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' },
|
||||||
return (
|
progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 },
|
||||||
<View style={styles.progressBg}>
|
progressFill:{ height: 2, backgroundColor: Colors.accent },
|
||||||
<View style={[styles.progressFill, { width: `${pct}%` as any }]} />
|
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 },
|
||||||
</View>
|
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' },
|
||||||
|
|
||||||
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' },
|
|
||||||
artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
||||||
btn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
controls: { flexDirection: 'row', alignItems: 'center' },
|
||||||
progressBg: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, backgroundColor: Colors.border },
|
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
|
||||||
progressFill:{ height: 2, backgroundColor: Colors.green },
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,100 @@
|
||||||
import { useCallback, useEffect, type ReactNode } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable, Dimensions } from 'react-native';
|
import {
|
||||||
|
View, Text, StyleSheet, Pressable, Dimensions, ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue, useAnimatedStyle, withSpring,
|
useSharedValue, useAnimatedStyle, withSpring,
|
||||||
interpolate, useAnimatedGestureHandler, runOnJS, FadeIn,
|
useAnimatedGestureHandler, runOnJS, FadeIn, interpolate,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { PanGestureHandler } from 'react-native-gesture-handler';
|
import { PanGestureHandler } from 'react-native-gesture-handler';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import Slider from '@react-native-community/slider';
|
import Slider from '@react-native-community/slider';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
import * as Haptics from 'expo-haptics';
|
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 { Colors, Font, Radius, Spacing } from '../constants/theme';
|
||||||
import { usePlayer } from '../store';
|
import { usePlayer, useLikes } from '../store';
|
||||||
import { useAudio } from '../hooks/useAudio';
|
import { useAudio } from '../hooks/useAudio';
|
||||||
import { coverUrl } from '../services/api';
|
import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const COVER_SIZE = width - 64;
|
const COVER = width - 48;
|
||||||
|
|
||||||
export default function Player() {
|
export default function Player() {
|
||||||
const {
|
const {
|
||||||
queue, index, playing, progress, duration,
|
queue, index, playing, progress, duration,
|
||||||
repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle,
|
repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle,
|
||||||
} = usePlayer();
|
} = usePlayer();
|
||||||
const { togglePlay, seek } = useAudio();
|
const { togglePlay, seek } = useAudio();
|
||||||
|
const { toggleLike, isLiked } = useLikes();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const track = queue[index];
|
const track = queue[index];
|
||||||
|
|
||||||
const translateY = useSharedValue(0);
|
const [lyricsOpen, setLyricsOpen] = useState(false);
|
||||||
const coverScale = useSharedValue(1);
|
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(() => {
|
useEffect(() => {
|
||||||
coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 });
|
coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 });
|
||||||
}, [playing]);
|
}, [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 }>({
|
const gestureHandler = useAnimatedGestureHandler<any, { startY: number }>({
|
||||||
onStart: (_, ctx) => { ctx.startY = translateY.value; },
|
onStart: (_, ctx) => { ctx.startY = translateY.value; },
|
||||||
onActive: (e, ctx) => {
|
onActive: (e, ctx) => {
|
||||||
if (e.translationY > 0) translateY.value = ctx.startY + e.translationY;
|
if (e.translationY > 0) translateY.value = ctx.startY + e.translationY;
|
||||||
},
|
},
|
||||||
onEnd: (e) => {
|
onEnd: (e) => {
|
||||||
if (e.translationY > 120 || e.velocityY > 800) {
|
if (e.translationY > 100 || e.velocityY > 700) runOnJS(close)();
|
||||||
runOnJS(close)();
|
else translateY.value = withSpring(0, { damping: 20 });
|
||||||
} else {
|
|
||||||
translateY.value = withSpring(0, { damping: 20 });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerStyle = useAnimatedStyle(() => ({
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateY: translateY.value }],
|
transform: [{ translateY: translateY.value }],
|
||||||
}));
|
}));
|
||||||
|
const overlayOpacity = useAnimatedStyle(() => ({
|
||||||
|
opacity: interpolate(translateY.value, [0, 300], [1, 0]),
|
||||||
|
}));
|
||||||
const coverStyle = useAnimatedStyle(() => ({
|
const coverStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ scale: coverScale.value }],
|
transform: [{ scale: coverScale.value }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const overlayStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: interpolate(translateY.value, [0, 300], [1, 0]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
|
|
@ -85,194 +120,221 @@ export default function Player() {
|
||||||
|
|
||||||
if (!track) return null;
|
if (!track) return null;
|
||||||
|
|
||||||
const cover = coverUrl(track.cover_url);
|
const cover = coverUrl(track.cover_url);
|
||||||
const pct = duration > 0 ? progress / duration : 0;
|
const pct = duration > 0 ? progress / duration : 0;
|
||||||
|
const liked = isLiked(track.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[StyleSheet.absoluteFill, styles.root, overlayStyle]} entering={FadeIn.duration(200)}>
|
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}>
|
||||||
{/* Blurred background art */}
|
{/* Blurred album art background */}
|
||||||
<Image
|
<Image
|
||||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
contentFit="cover"
|
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}>
|
<PanGestureHandler onGestureEvent={gestureHandler}>
|
||||||
<Animated.View style={[
|
<Animated.View style={[s.sheet, containerStyle, {
|
||||||
styles.sheet, containerStyle,
|
paddingTop: insets.top + 8,
|
||||||
{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 24 },
|
paddingBottom: insets.bottom + 16,
|
||||||
]}>
|
}]}>
|
||||||
{/* Handle */}
|
|
||||||
<Pressable onPress={close} hitSlop={20} style={styles.handleWrap}>
|
{/* Drag handle + top label */}
|
||||||
<View style={styles.handle} />
|
<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>
|
</Pressable>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.headerTitle}>Сейчас играет</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Cover */}
|
{/* Cover */}
|
||||||
<View style={styles.coverWrap}>
|
<View style={s.coverWrap}>
|
||||||
<Animated.View style={[styles.coverShadow, coverStyle]}>
|
<Animated.View style={[s.coverShadow, coverStyle]}>
|
||||||
<Image
|
<Image
|
||||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
style={styles.cover}
|
style={s.cover}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
transition={300}
|
transition={300}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Info + like */}
|
{/* Meta row */}
|
||||||
<View style={styles.infoRow}>
|
<View style={s.metaRow}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={s.metaText}>
|
||||||
<Text style={styles.trackTitle} numberOfLines={1}>{track.title}</Text>
|
<Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text>
|
||||||
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Seek bar */}
|
||||||
<View style={styles.progressWrap}>
|
<View style={s.seekWrap}>
|
||||||
<Slider
|
<Slider
|
||||||
style={{ width: '100%', height: 36 }}
|
style={{ width: '100%', height: 32 }}
|
||||||
minimumValue={0}
|
minimumValue={0}
|
||||||
maximumValue={1}
|
maximumValue={1}
|
||||||
value={pct}
|
value={pct}
|
||||||
minimumTrackTintColor={Colors.text}
|
minimumTrackTintColor={Colors.text}
|
||||||
maximumTrackTintColor={Colors.text3}
|
maximumTrackTintColor="rgba(255,255,255,0.2)"
|
||||||
thumbTintColor={Colors.text}
|
thumbTintColor={Colors.text}
|
||||||
onSlidingComplete={(v) => seek(v * duration)}
|
onSlidingComplete={(v) => seek(v * duration)}
|
||||||
/>
|
/>
|
||||||
<View style={styles.timeRow}>
|
<View style={s.timeRow}>
|
||||||
<Text style={styles.timeText}>{fmt(progress)}</Text>
|
<Text style={s.timeText}>{fmt(progress)}</Text>
|
||||||
<Text style={styles.timeText}>{fmt(duration)}</Text>
|
<Text style={s.timeText}>{fmt(duration)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<View style={styles.controls}>
|
<View style={s.controls}>
|
||||||
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
|
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
|
||||||
<ShuffleIcon active={shuffle} />
|
<ShuffleIcon active={shuffle} />
|
||||||
</CtlBtn>
|
</CtlBtn>
|
||||||
|
|
||||||
<CtlBtn onPress={onPrev}>
|
<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>
|
</CtlBtn>
|
||||||
|
<Pressable style={s.playBtn} onPress={onPlay}>
|
||||||
<Pressable style={styles.playBtn} onPress={onPlay}>
|
{playing
|
||||||
{playing ? <PauseIcon /> : <PlayIcon />}
|
? <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>
|
</Pressable>
|
||||||
|
|
||||||
<CtlBtn onPress={onNext}>
|
<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>
|
||||||
|
|
||||||
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
|
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
|
||||||
<RepeatIcon repeat={repeat} />
|
<RepeatIcon repeat={repeat} />
|
||||||
</CtlBtn>
|
</CtlBtn>
|
||||||
</View>
|
</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>
|
</Animated.View>
|
||||||
</PanGestureHandler>
|
</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>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CtlBtn({ onPress, active = false, children }: {
|
function CtlBtn({ onPress, active = false, children }: { onPress: () => void; active?: boolean; children: React.ReactNode }) {
|
||||||
onPress: () => void; active?: boolean; children: React.ReactNode;
|
const sc = useSharedValue(1);
|
||||||
}) {
|
const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] }));
|
||||||
const scale = useSharedValue(1);
|
|
||||||
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={style}>
|
<Animated.View style={st}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.ctlBtn, active && styles.ctlBtnActive]}
|
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onPressIn={() => { scale.value = withSpring(0.82, { damping: 12 }); }}
|
onPressIn={() => { sc.value = withSpring(0.82, { damping: 12 }); }}
|
||||||
onPressOut={() => { scale.value = withSpring(1, { damping: 12 }); }}
|
onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }}
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
>
|
style={[s.ctlBtn, active && s.ctlBtnActive]}
|
||||||
{children}
|
>{children}</Pressable>
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
</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 }) {
|
function ShuffleIcon({ active }: { active: boolean }) {
|
||||||
const c = active ? Colors.green : Colors.text2;
|
const c = active ? Colors.accent : 'rgba(255,255,255,0.5)';
|
||||||
return (
|
return (
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||||
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<Path d="M4 20L21 3" stroke={c} strokeWidth={1.8} 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={1.8} 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={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
|
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 (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||||
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
<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={1.8} 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={1.8} 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={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
{repeat === 2 && (
|
{repeat === 2 && (
|
||||||
<View style={styles.repeatBadge}>
|
<View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>
|
||||||
<Text style={styles.repeatBadgeText}>1</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -283,27 +345,46 @@ function fmt(s: number) {
|
||||||
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
root: { zIndex: 999 },
|
root: { zIndex: 999 },
|
||||||
dim: { backgroundColor: 'rgba(0,0,0,0.6)' },
|
bgDim: { backgroundColor: 'rgba(0,0,0,0.65)' },
|
||||||
sheet: { flex: 1, paddingHorizontal: Spacing.xl },
|
sheet: { flex: 1, paddingHorizontal: 24 },
|
||||||
handleWrap: { alignItems: 'center', paddingBottom: 4 },
|
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 8 },
|
||||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)' },
|
handle: { display: 'none' },
|
||||||
header: { alignItems: 'center', marginBottom: Spacing.lg },
|
topLabel: { color: 'rgba(255,255,255,0.38)', fontSize: 11, fontWeight: '600', letterSpacing: 1.2, textTransform: 'uppercase' },
|
||||||
headerTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600', letterSpacing: 0.5 },
|
closeBtn: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.1)', alignItems: 'center', justifyContent: 'center' },
|
||||||
coverWrap: { alignItems: 'center', marginBottom: Spacing.xl },
|
coverWrap: { alignItems: 'center', marginVertical: 20 },
|
||||||
coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.6, shadowRadius: 32 },
|
coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.7, shadowRadius: 32 },
|
||||||
cover: { width: COVER_SIZE, height: COVER_SIZE, borderRadius: Radius.xl, backgroundColor: Colors.elevated },
|
cover: { width: COVER, height: COVER, borderRadius: Radius.xl, backgroundColor: Colors.elevated },
|
||||||
infoRow: { flexDirection: 'row', alignItems: 'center', marginBottom: Spacing.md, gap: Spacing.md },
|
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 12 },
|
||||||
trackTitle: { color: Colors.text, fontSize: Font.xl, fontWeight: '700', letterSpacing: -0.3 },
|
metaText: { flex: 1 },
|
||||||
artist: { color: 'rgba(255,255,255,0.5)', fontSize: Font.md, marginTop: 4 },
|
trackTitle: { color: Colors.text, fontSize: 22, fontWeight: '700', letterSpacing: -0.3 },
|
||||||
progressWrap: { marginBottom: Spacing.lg },
|
artist: { color: 'rgba(255,255,255,0.55)', fontSize: 17, marginTop: 4 },
|
||||||
timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -6 },
|
seekWrap: { marginBottom: 8 },
|
||||||
timeText: { color: 'rgba(255,255,255,0.35)', fontSize: Font.xs },
|
timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 },
|
||||||
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4 },
|
timeText: { color: 'rgba(255,255,255,0.35)', fontSize: Font.xs },
|
||||||
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full },
|
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 20 },
|
||||||
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
|
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full },
|
||||||
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 },
|
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
|
||||||
repeatBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.green, alignItems: 'center', justifyContent: 'center' },
|
playBtn: { width: 60, height: 60, borderRadius: 30, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center' },
|
||||||
repeatBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' },
|
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 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
export const Colors = {
|
export const Colors = {
|
||||||
bg: '#0a0a0a',
|
bg: '#0a0a0a',
|
||||||
surface: '#141414',
|
surface: '#111111',
|
||||||
elevated: '#1e1e1e',
|
elevated: '#1a1a1a',
|
||||||
border: '#2a2a2a',
|
subtle: '#2a2a2a',
|
||||||
green: '#1db954',
|
border: 'rgba(255,255,255,0.07)',
|
||||||
greenDim: '#168f3f',
|
border2: 'rgba(255,255,255,0.14)',
|
||||||
|
accent: '#4F86F5',
|
||||||
|
accentDim:'rgba(79,134,245,0.18)',
|
||||||
|
pink: '#e84393',
|
||||||
text: '#f0f0f0',
|
text: '#f0f0f0',
|
||||||
text2: '#888888',
|
text2: '#888888',
|
||||||
text3: '#444444',
|
text3: '#444444',
|
||||||
red: '#e84040',
|
red: '#e84040',
|
||||||
white: '#ffffff',
|
white: '#ffffff',
|
||||||
|
// legacy alias
|
||||||
|
green: '#4F86F5',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Spacing = {
|
export const Spacing = {
|
||||||
|
|
@ -23,21 +28,21 @@ export const Spacing = {
|
||||||
|
|
||||||
export const Radius = {
|
export const Radius = {
|
||||||
sm: 8,
|
sm: 8,
|
||||||
md: 12,
|
md: 10,
|
||||||
lg: 16,
|
lg: 14,
|
||||||
xl: 20,
|
xl: 16,
|
||||||
full: 9999,
|
full: 9999,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Font = {
|
export const Font = {
|
||||||
xs: 11,
|
xs: 11,
|
||||||
sm: 13,
|
sm: 13,
|
||||||
md: 15,
|
md: 14,
|
||||||
lg: 17,
|
lg: 17,
|
||||||
xl: 20,
|
xl: 20,
|
||||||
xxl: 26,
|
xxl: 26,
|
||||||
xxxl: 32,
|
xxxl: 32,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TAB_BAR_HEIGHT = 88;
|
export const TAB_BAR_HEIGHT = 54;
|
||||||
export const MINI_PLAYER_HEIGHT = 64;
|
export const MINI_PLAYER_HEIGHT = 64;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@
|
||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-reanimated": "~3.10.1",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-safe-area-context": "4.10.5",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"react-native-svg": "^15.0.0",
|
|
||||||
"zustand": "^4.5.4",
|
"zustand": "^4.5.4",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/slider": "4.5.2"
|
"@react-native-community/slider": "4.5.2"
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,25 @@
|
||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
const BASE = 'https://music.nodeshift.space';
|
const BASE = 'https://music.nodeshift.space';
|
||||||
const CLOUD = 'https://cloud.nodeshift.space';
|
const CLOUD = 'https://cloud.nodeshift.space';
|
||||||
const CLIENT_ID = 'nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U';
|
const CLIENT_ID = 'nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U';
|
||||||
const REDIRECT = 'burmalda://auth/callback';
|
const REDIRECT = 'burmalda://auth/callback';
|
||||||
|
|
||||||
// ── Token storage ──────────────────────────────────────────────────────────────
|
// ── Token storage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function saveTokens(access: string, refresh: string) {
|
export async function saveTokens(access: string, refresh: string) {
|
||||||
await SecureStore.setItemAsync('access_token', access);
|
await SecureStore.setItemAsync('access_token', access);
|
||||||
await SecureStore.setItemAsync('refresh_token', refresh);
|
await SecureStore.setItemAsync('refresh_token', refresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAccessToken(): Promise<string | null> {
|
export async function getAccessToken(): Promise<string | null> {
|
||||||
return SecureStore.getItemAsync('access_token');
|
return SecureStore.getItemAsync('access_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearTokens() {
|
export async function clearTokens() {
|
||||||
await SecureStore.deleteItemAsync('access_token');
|
await SecureStore.deleteItemAsync('access_token');
|
||||||
await SecureStore.deleteItemAsync('refresh_token');
|
await SecureStore.deleteItemAsync('refresh_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth fetch helper ──────────────────────────────────────────────────────────
|
// ── Fetch helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
|
|
@ -33,15 +31,10 @@ async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response>
|
||||||
return fetch(`${BASE}${path}`, { ...opts, headers });
|
return fetch(`${BASE}${path}`, { ...opts, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── OAuth ──────────────────────────────────────────────────────────────────────
|
// ── OAuth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function buildOAuthUrl(state: string): string {
|
export function buildOAuthUrl(state: string): string {
|
||||||
const p = new URLSearchParams({
|
const p = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT, state, scope: 'profile email' });
|
||||||
client_id: CLIENT_ID,
|
|
||||||
redirect_uri: REDIRECT,
|
|
||||||
state,
|
|
||||||
scope: 'profile email',
|
|
||||||
});
|
|
||||||
return `${CLOUD}/api/cloud/oauth/authorize?${p}`;
|
return `${CLOUD}/api/cloud/oauth/authorize?${p}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +51,7 @@ export async function exchangeCode(code: string): Promise<{ access_token: string
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -88,7 +81,12 @@ export interface User {
|
||||||
plan: string;
|
plan: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── API calls ──────────────────────────────────────────────────────────────────
|
export interface Lyrics {
|
||||||
|
synced: string | null;
|
||||||
|
plain: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API calls ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getTracks(): Promise<Track[]> {
|
export async function getTracks(): Promise<Track[]> {
|
||||||
const r = await apiFetch('/api/music/tracks');
|
const r = await apiFetch('/api/music/tracks');
|
||||||
|
|
@ -114,6 +112,12 @@ export async function searchTracks(q: string): Promise<Track[]> {
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLyrics(id: number): Promise<Lyrics | null> {
|
||||||
|
const r = await apiFetch(`/api/music/lyrics/${id}`);
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
export function streamUrl(filename: string): string {
|
export function streamUrl(filename: string): string {
|
||||||
return `${BASE}/api/music/stream/${filename}`;
|
return `${BASE}/api/music/stream/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
@ -123,3 +127,21 @@ export function coverUrl(filename: string | null): string | null {
|
||||||
if (filename.startsWith('http')) return filename;
|
if (filename.startsWith('http')) return filename;
|
||||||
return `${BASE}/api/music/cover/${filename}`;
|
return `${BASE}/api/music/cover/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LRC parser ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LrcLine {
|
||||||
|
time: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLrc(lrc: string): LrcLine[] {
|
||||||
|
const lines: LrcLine[] = [];
|
||||||
|
for (const raw of lrc.split('\n')) {
|
||||||
|
const m = raw.match(/^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const time = parseInt(m[1]) * 60 + parseFloat(`${m[2]}.${m[3]}`);
|
||||||
|
if (m[4].trim()) lines.push({ time, text: m[4].trim() });
|
||||||
|
}
|
||||||
|
return lines.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,29 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Track, User } from '../services/api';
|
import { Track, User } from '../services/api';
|
||||||
|
|
||||||
|
// ── Player ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
queue: Track[];
|
queue: Track[];
|
||||||
index: number;
|
index: number;
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
progress: number;
|
progress: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
repeat: 0 | 1 | 2; // 0=off 1=all 2=one
|
repeat: 0 | 1 | 2;
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
|
|
||||||
setQueue: (tracks: Track[], index?: number) => void;
|
setQueue: (tracks: Track[], index?: number) => void;
|
||||||
setIndex: (i: number) => void;
|
setIndex: (i: number) => void;
|
||||||
setPlaying: (v: boolean) => void;
|
setPlaying: (v: boolean) => void;
|
||||||
setProgress:(v: number) => void;
|
setProgress: (v: number) => void;
|
||||||
setDuration:(v: number) => void;
|
setDuration: (v: number) => void;
|
||||||
cycleRepeat:() => void;
|
cycleRepeat: () => void;
|
||||||
toggleShuffle: () => void;
|
toggleShuffle: () => void;
|
||||||
setExpanded:(v: boolean) => void;
|
setExpanded: (v: boolean) => void;
|
||||||
nextTrack: () => number;
|
nextTrack: () => number;
|
||||||
prevTrack: () => number;
|
prevTrack: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePlayer = create<PlayerState>((set, get) => ({
|
export const usePlayer = create<PlayerState>((set, get) => ({
|
||||||
|
|
@ -33,14 +36,14 @@ export const usePlayer = create<PlayerState>((set, get) => ({
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
|
|
||||||
setQueue: (tracks, index = 0) => set({ queue: tracks, index }),
|
setQueue: (tracks, index = 0) => set({ queue: tracks, index }),
|
||||||
setIndex: (index) => set({ index }),
|
setIndex: (index) => set({ index }),
|
||||||
setPlaying: (playing) => set({ playing }),
|
setPlaying: (playing) => set({ playing }),
|
||||||
setProgress: (progress) => set({ progress }),
|
setProgress: (progress) => set({ progress }),
|
||||||
setDuration: (duration) => set({ duration }),
|
setDuration: (duration) => set({ duration }),
|
||||||
cycleRepeat: () => set((s) => ({ repeat: ((s.repeat + 1) % 3) as 0 | 1 | 2 })),
|
cycleRepeat: () => set((s) => ({ repeat: ((s.repeat + 1) % 3) as 0 | 1 | 2 })),
|
||||||
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
|
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
|
||||||
setExpanded: (expanded) => set({ expanded }),
|
setExpanded: (expanded) => set({ expanded }),
|
||||||
|
|
||||||
nextTrack: () => {
|
nextTrack: () => {
|
||||||
const { queue, index, shuffle, repeat } = get();
|
const { queue, index, shuffle, repeat } = get();
|
||||||
|
|
@ -50,7 +53,6 @@ export const usePlayer = create<PlayerState>((set, get) => ({
|
||||||
if (next >= queue.length) return repeat === 1 ? 0 : index;
|
if (next >= queue.length) return repeat === 1 ? 0 : index;
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
|
|
||||||
prevTrack: () => {
|
prevTrack: () => {
|
||||||
const { queue, index, shuffle } = get();
|
const { queue, index, shuffle } = get();
|
||||||
if (shuffle) return Math.floor(Math.random() * queue.length);
|
if (shuffle) return Math.floor(Math.random() * queue.length);
|
||||||
|
|
@ -58,16 +60,50 @@ export const usePlayer = create<PlayerState>((set, get) => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
setUser: (u: User | null) => void;
|
setUser: (u: User | null) => void;
|
||||||
setReady:(v: boolean) => void;
|
setReady: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuth = create<AuthState>((set) => ({
|
export const useAuth = create<AuthState>((set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
ready: false,
|
ready: false,
|
||||||
setUser: (user) => set({ user }),
|
setUser: (user) => set({ user }),
|
||||||
setReady:(ready) => set({ ready }),
|
setReady: (ready) => set({ ready }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Likes ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LIKES_KEY = 'ns-music-likes';
|
||||||
|
|
||||||
|
interface LikesState {
|
||||||
|
likes: Set<number>;
|
||||||
|
loadLikes: () => Promise<void>;
|
||||||
|
toggleLike: (id: number) => void;
|
||||||
|
isLiked: (id: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLikes = create<LikesState>((set, get) => ({
|
||||||
|
likes: new Set(),
|
||||||
|
|
||||||
|
loadLikes: async () => {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(LIKES_KEY);
|
||||||
|
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||||||
|
set({ likes: new Set(arr) });
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleLike: (id) => {
|
||||||
|
const next = new Set(get().likes);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
set({ likes: next });
|
||||||
|
AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
isLiked: (id) => get().likes.has(id),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue