Add files via upload
This commit is contained in:
parent
d612a65f81
commit
5748d2d13c
31 changed files with 3197 additions and 2492 deletions
|
|
@ -1,111 +1,111 @@
|
|||
import { useEffect } from 'react';
|
||||
import { View, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { Tabs, router } from 'expo-router';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
|
||||
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
|
||||
import { useAuth, usePlayer, useLikes } from '../../store';
|
||||
import { useAudio } from '../../hooks/useAudio';
|
||||
import MiniPlayer from '../../components/MiniPlayer';
|
||||
import Player from '../../components/Player';
|
||||
|
||||
export default function TabsLayout() {
|
||||
const { user, ready } = useAuth();
|
||||
const { expanded } = usePlayer();
|
||||
const { loadLikes } = useLikes();
|
||||
useAudio(); // single audio controller for the whole app
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !user) router.replace('/auth');
|
||||
}, [user, ready]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) loadLikes();
|
||||
}, [user]);
|
||||
|
||||
if (!ready) return (
|
||||
<View style={{ flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator color={Colors.accent} />
|
||||
</View>
|
||||
);
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: Colors.bg }}>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: styles.tabBar,
|
||||
tabBarBackground: () => (
|
||||
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
),
|
||||
tabBarActiveTintColor: Colors.text,
|
||||
tabBarInactiveTintColor: Colors.text3,
|
||||
tabBarLabelStyle: { fontSize: 9, fontWeight: '500', letterSpacing: 0.3, marginBottom: 4 },
|
||||
tabBarItemStyle: { paddingTop: 6 },
|
||||
}}
|
||||
>
|
||||
<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="search" options={{ title: 'Поиск', tabBarIcon: ({ color }) => <SearchIcon 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>
|
||||
|
||||
<MiniPlayer />
|
||||
{expanded && <Player />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<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" />
|
||||
<Polyline points="9,22 9,12 15,12 15,22" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
function LibIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
<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={18} cy={16} r={3} stroke={color} strokeWidth={1.8} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
function SearchIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
<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" />
|
||||
</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 }) {
|
||||
return (
|
||||
<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" />
|
||||
<Circle cx={12} cy={7} r={4} stroke={color} strokeWidth={1.8} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBar: {
|
||||
position: 'absolute',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
||||
backgroundColor: 'transparent',
|
||||
elevation: 0,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
},
|
||||
});
|
||||
import { useEffect } from 'react';
|
||||
import { View, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { Tabs, router } from 'expo-router';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
|
||||
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
|
||||
import { useAuth, usePlayer, useLikes } from '../../store';
|
||||
import { useAudio } from '../../hooks/useAudio';
|
||||
import MiniPlayer from '../../components/MiniPlayer';
|
||||
import Player from '../../components/Player';
|
||||
|
||||
export default function TabsLayout() {
|
||||
const { user, ready } = useAuth();
|
||||
const { expanded } = usePlayer();
|
||||
const { loadLikes } = useLikes();
|
||||
useAudio(); // single audio controller for the whole app
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !user) router.replace('/auth');
|
||||
}, [user, ready]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) loadLikes();
|
||||
}, [user]);
|
||||
|
||||
if (!ready) return (
|
||||
<View style={{ flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator color={Colors.accent} />
|
||||
</View>
|
||||
);
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: Colors.bg }}>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: styles.tabBar,
|
||||
tabBarBackground: () => (
|
||||
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
),
|
||||
tabBarActiveTintColor: Colors.text,
|
||||
tabBarInactiveTintColor: Colors.text3,
|
||||
tabBarLabelStyle: { fontSize: 9, fontWeight: '500', letterSpacing: 0.3, marginBottom: 4 },
|
||||
tabBarItemStyle: { paddingTop: 6 },
|
||||
}}
|
||||
>
|
||||
<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="search" options={{ title: 'Поиск', tabBarIcon: ({ color }) => <SearchIcon 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>
|
||||
|
||||
<MiniPlayer />
|
||||
{expanded && <Player />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<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" />
|
||||
<Polyline points="9,22 9,12 15,12 15,22" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
function LibIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
<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={18} cy={16} r={3} stroke={color} strokeWidth={1.8} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
function SearchIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
<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" />
|
||||
</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 }) {
|
||||
return (
|
||||
<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" />
|
||||
<Circle cx={12} cy={7} r={4} stroke={color} strokeWidth={1.8} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBar: {
|
||||
position: 'absolute',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
||||
backgroundColor: 'transparent',
|
||||
elevation: 0,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,211 +1,290 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView, StyleSheet, Pressable, 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 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 { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
|
||||
import { usePlayer, useLikes } from '../../store';
|
||||
import CoverImage from '../../components/CoverImage';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const CARD_W = (width - Spacing.md * 3) / 2;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [tracks, setTracks] = useState<Track[]>([]);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setQueue } = usePlayer();
|
||||
const { toggleLike, isLiked } = useLikes();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [t, a] = await Promise.all([getTracks(), getAlbums()]);
|
||||
setTracks(t);
|
||||
setAlbums(a);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefresh(true);
|
||||
await load();
|
||||
setRefresh(false);
|
||||
};
|
||||
|
||||
const playTrack = (track: Track, list: Track[], idx: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setQueue(list, idx);
|
||||
usePlayer.getState().setExpanded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.content, { paddingTop: insets.top + 14, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }]}
|
||||
refreshControl={<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.text2} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Animated.View entering={FadeIn.duration(400)} style={styles.pageHeader}>
|
||||
<Text style={styles.pageTitle}>NodeShift Music</Text>
|
||||
</Animated.View>
|
||||
|
||||
<NowPlayingStrip />
|
||||
|
||||
{albums.length > 0 && (
|
||||
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
||||
<Text style={styles.secTitle}>Альбомы</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hRow}>
|
||||
{albums.map((a, i) => (
|
||||
<AlbumCard key={a.id} album={a} delay={i * 50} onPress={() => {
|
||||
const albumTracks = tracks.filter(t => t.album === a.title);
|
||||
if (albumTracks.length > 0) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setQueue(albumTracks, 0);
|
||||
usePlayer.getState().setExpanded(true);
|
||||
}
|
||||
}} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{tracks.length > 0 && (
|
||||
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
|
||||
<Text style={styles.secTitle}>Треки</Text>
|
||||
{tracks.map((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>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
{/* Blurred cover background */}
|
||||
<CoverImage
|
||||
uri={cover}
|
||||
title={track.title}
|
||||
size={160}
|
||||
radius={0}
|
||||
blurRadius={40}
|
||||
style={StyleSheet.absoluteFill as any}
|
||||
/>
|
||||
<View style={[StyleSheet.absoluteFill, styles.npDim]} />
|
||||
<Pressable style={styles.npContent} onPress={() => setExpanded(true)}>
|
||||
<CoverImage uri={cover} title={track.title} size={52} radius={Radius.sm} />
|
||||
<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, onPress }: { album: Album; delay: number; onPress: () => void }) {
|
||||
const scale = useSharedValue(1);
|
||||
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||
const cover = coverUrl(album.cover_url);
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(delay)} style={style}>
|
||||
<Pressable
|
||||
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
|
||||
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||
onPress={onPress}
|
||||
style={styles.albumCard}
|
||||
>
|
||||
<CoverImage uri={cover} title={album.title} size={CARD_W} radius={Radius.md} style={{ marginBottom: Spacing.sm }} />
|
||||
<Text style={styles.albumTitle} numberOfLines={1}>{album.title}</Text>
|
||||
<Text style={styles.albumArtist} numberOfLines={1}>{album.artist}</Text>
|
||||
<Text style={styles.albumCount}>{album.track_count} треков</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackRow({ track, index, liked, onPress, onLike }: {
|
||||
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
|
||||
}) {
|
||||
const { queue, index: qi } = usePlayer();
|
||||
const active = queue[qi]?.id === track.id;
|
||||
const cover = coverUrl(track.cover_url);
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
|
||||
<Text style={styles.trackNum}>{index + 1}</Text>
|
||||
<CoverImage uri={cover} title={track.title} size={44} radius={Radius.sm} />
|
||||
<View style={styles.trackInfo}>
|
||||
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
|
||||
</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.trackDur}>{fmt(track.duration)}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
scroll: { flex: 1, backgroundColor: Colors.bg },
|
||||
content: { paddingHorizontal: Spacing.md },
|
||||
pageHeader: { marginBottom: Spacing.md },
|
||||
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
|
||||
section: { marginBottom: Spacing.xl },
|
||||
secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
|
||||
hRow: { gap: Spacing.md, paddingRight: Spacing.md },
|
||||
albumCard: { width: CARD_W },
|
||||
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
||||
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
||||
albumCount: { color: Colors.text3, fontSize: Font.xs, marginTop: 2 },
|
||||
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: Spacing.sm },
|
||||
trackRowActive: { backgroundColor: Colors.surface },
|
||||
trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||
trackInfo: { flex: 1 },
|
||||
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||
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.5)' },
|
||||
npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
||||
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 },
|
||||
});
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl, ActivityIndicator } from 'react-native';
|
||||
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import * as Network from 'expo-network';
|
||||
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 { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
|
||||
import { downloadTrack, deleteTrack, isTrackDownloaded, DownloadProgress, cacheTracksAndAlbums, getCachedTracks, getCachedAlbums, initOfflineStorage } from '../../services/offline';
|
||||
import { usePlayer, useLikes, useOffline } from '../../store';
|
||||
import CoverImage from '../../components/CoverImage';
|
||||
import DownloadButton from '../../components/DownloadButton';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const CARD_W = (width - Spacing.md * 3) / 2;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [tracks, setTracks] = useState<Track[]>([]);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setQueue } = usePlayer();
|
||||
const { toggleLike, isLiked } = useLikes();
|
||||
const { setOnline, loadDownloadedTracks } = useOffline();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Check network
|
||||
const isOnline = await Network.getNetworkStateAsync();
|
||||
setOnline(isOnline.isConnected);
|
||||
|
||||
if (isOnline.isConnected) {
|
||||
try {
|
||||
const [t, a] = await Promise.all([getTracks(), getAlbums()]);
|
||||
if (t.length > 0) {
|
||||
setTracks(t);
|
||||
setAlbums(a);
|
||||
await cacheTracksAndAlbums(t, a);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch online:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to cached data
|
||||
const cachedTracks = await getCachedTracks();
|
||||
const cachedAlbums = await getCachedAlbums();
|
||||
if (cachedTracks) setTracks(cachedTracks);
|
||||
if (cachedAlbums) setAlbums(cachedAlbums);
|
||||
setLoading(false);
|
||||
}, [setOnline]);
|
||||
|
||||
useEffect(() => {
|
||||
initOfflineStorage();
|
||||
loadDownloadedTracks();
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useFocusEffect(() => {
|
||||
loadDownloadedTracks();
|
||||
});
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefresh(true);
|
||||
await load();
|
||||
setRefresh(false);
|
||||
};
|
||||
|
||||
const playTrack = (track: Track, list: Track[], idx: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setQueue(list, idx);
|
||||
usePlayer.getState().setExpanded(true);
|
||||
};
|
||||
|
||||
if (loading && tracks.length === 0) {
|
||||
return (
|
||||
<View style={[styles.scroll, { justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<ActivityIndicator size="large" color={Colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.content, { paddingTop: insets.top + 14, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }]}
|
||||
refreshControl={<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.text2} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Animated.View entering={FadeIn.duration(400)} style={styles.pageHeader}>
|
||||
<Text style={styles.pageTitle}>NodeShift Music</Text>
|
||||
</Animated.View>
|
||||
|
||||
<NowPlayingStrip />
|
||||
|
||||
{albums.length > 0 && (
|
||||
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
||||
<Text style={styles.secTitle}>Альбомы</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hRow}>
|
||||
{albums.map((a, i) => (
|
||||
<AlbumCard key={a.id} album={a} delay={i * 50} onPress={() => {
|
||||
const albumTracks = tracks.filter(t => t.album === a.title);
|
||||
if (albumTracks.length > 0) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setQueue(albumTracks, 0);
|
||||
usePlayer.getState().setExpanded(true);
|
||||
}
|
||||
}} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{tracks.length > 0 && (
|
||||
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
|
||||
<Text style={styles.secTitle}>Треки</Text>
|
||||
{tracks.map((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>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
{/* Blurred cover background */}
|
||||
<CoverImage
|
||||
uri={cover}
|
||||
title={track.title}
|
||||
size={160}
|
||||
radius={0}
|
||||
blurRadius={40}
|
||||
style={StyleSheet.absoluteFill as any}
|
||||
/>
|
||||
<View style={[StyleSheet.absoluteFill, styles.npDim]} />
|
||||
<Pressable style={styles.npContent} onPress={() => setExpanded(true)}>
|
||||
<CoverImage uri={cover} title={track.title} size={52} radius={Radius.sm} />
|
||||
<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, onPress }: { album: Album; delay: number; onPress: () => void }) {
|
||||
const scale = useSharedValue(1);
|
||||
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||
const cover = coverUrl(album.cover_url);
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(delay)} style={style}>
|
||||
<Pressable
|
||||
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
|
||||
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||
onPress={onPress}
|
||||
style={styles.albumCard}
|
||||
>
|
||||
<CoverImage uri={cover} title={album.title} size={CARD_W} radius={Radius.md} style={{ marginBottom: Spacing.sm }} />
|
||||
<Text style={styles.albumTitle} numberOfLines={1}>{album.title}</Text>
|
||||
<Text style={styles.albumArtist} numberOfLines={1}>{album.artist}</Text>
|
||||
<Text style={styles.albumCount}>{album.track_count} треков</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackRow({ track, index, liked, onPress, onLike }: {
|
||||
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
|
||||
}) {
|
||||
const { queue, index: qi } = usePlayer();
|
||||
const { downloadedTracks, downloading, addDownloadedTrack, removeDownloadedTrack, setDownloading } = useOffline();
|
||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||
const active = queue[qi]?.id === track.id;
|
||||
const cover = coverUrl(track.cover_url);
|
||||
const downloadProgress = downloading.get(track.id) || 0;
|
||||
|
||||
useEffect(() => {
|
||||
setIsDownloaded(downloadedTracks.has(track.id));
|
||||
}, [downloadedTracks, track.id]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDownloaded) {
|
||||
await deleteTrack(track.id);
|
||||
removeDownloadedTrack(track.id);
|
||||
setIsDownloaded(false);
|
||||
} else {
|
||||
try {
|
||||
const success = await downloadTrack(track, (prog) => {
|
||||
setDownloading(track.id, prog.progress);
|
||||
});
|
||||
if (success) {
|
||||
addDownloadedTrack(track.id);
|
||||
setIsDownloaded(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Download failed:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
|
||||
<Text style={styles.trackNum}>{index + 1}</Text>
|
||||
<CoverImage uri={cover} title={track.title} size={44} radius={Radius.sm} />
|
||||
<View style={styles.trackInfo}>
|
||||
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
|
||||
</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>
|
||||
<DownloadButton
|
||||
trackId={track.id}
|
||||
isDownloaded={isDownloaded}
|
||||
downloadProgress={downloadProgress}
|
||||
onDownload={handleDownload}
|
||||
onDelete={handleDownload}
|
||||
size="sm"
|
||||
/>
|
||||
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
scroll: { flex: 1, backgroundColor: Colors.bg },
|
||||
content: { paddingHorizontal: Spacing.md },
|
||||
pageHeader: { marginBottom: Spacing.md },
|
||||
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
|
||||
section: { marginBottom: Spacing.xl },
|
||||
secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
|
||||
hRow: { gap: Spacing.md, paddingRight: Spacing.md },
|
||||
albumCard: { width: CARD_W },
|
||||
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
||||
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
||||
albumCount: { color: Colors.text3, fontSize: Font.xs, marginTop: 2 },
|
||||
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: 8 },
|
||||
trackRowActive: { backgroundColor: Colors.surface },
|
||||
trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||
trackInfo: { flex: 1 },
|
||||
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||
trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||
trackDur: { color: Colors.text3, fontSize: Font.xs, width: 36, textAlign: 'right' },
|
||||
// Now playing strip
|
||||
npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 },
|
||||
npDim: { backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
||||
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,140 +1,140 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { View, Text, FlatList, StyleSheet, TextInput, 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, { 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 { getTracks, Track, coverUrl } from '../../services/api';
|
||||
import { usePlayer, useLikes } from '../../store';
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const [all, setAll] = useState<Track[]>([]);
|
||||
const [filtered, setFiltered] = useState<Track[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setQueue } = usePlayer();
|
||||
const { toggleLike, isLiked } = useLikes();
|
||||
|
||||
useEffect(() => {
|
||||
getTracks().then(t => { setAll(t); setFiltered(t); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.toLowerCase();
|
||||
setFiltered(q ? all.filter(t =>
|
||||
t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q)
|
||||
) : all);
|
||||
}, [query, all]);
|
||||
|
||||
const playTrack = (idx: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setQueue(filtered, idx);
|
||||
usePlayer.getState().setExpanded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Библиотека</Text>
|
||||
<Text style={styles.count}>{all.length} треков</Text>
|
||||
</View>
|
||||
|
||||
{/* Search */}
|
||||
<View style={styles.searchWrap}>
|
||||
<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
|
||||
style={styles.searchInput}
|
||||
placeholder="Поиск треков..."
|
||||
placeholderTextColor={Colors.text3}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
returnKeyType="search"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{!!query && (
|
||||
<Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}>
|
||||
<Text style={{ color: Colors.text3, fontSize: 12 }}>✕</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={filtered}
|
||||
keyExtractor={t => String(t.id)}
|
||||
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item, index }) => (
|
||||
<TrackItem
|
||||
track={item}
|
||||
index={index}
|
||||
liked={isLiked(item.id)}
|
||||
onPress={() => playTrack(index)}
|
||||
onLike={() => toggleLike(item.id)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackItem({ track, index, liked, onPress, onLike }: {
|
||||
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
|
||||
}) {
|
||||
const { queue, index: qi } = usePlayer();
|
||||
const active = queue[qi]?.id === track.id;
|
||||
const cover = coverUrl(track.cover_url);
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(Math.min(index * 25, 300)).duration(300)}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.row, active && styles.rowActive]}
|
||||
>
|
||||
<Text style={styles.num}>{index + 1}</Text>
|
||||
<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}>{track.title}</Text>
|
||||
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||
</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>
|
||||
</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: '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, flex: 1 },
|
||||
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: 8 },
|
||||
searchInput:{ flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11, paddingRight: Spacing.md },
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: Spacing.sm, borderRadius: Radius.sm, paddingHorizontal: 4 },
|
||||
rowActive: { backgroundColor: Colors.surface },
|
||||
num: { width: 22, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||
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 },
|
||||
});
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { View, Text, FlatList, StyleSheet, TextInput, 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, { 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 { getTracks, Track, coverUrl } from '../../services/api';
|
||||
import { usePlayer, useLikes } from '../../store';
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const [all, setAll] = useState<Track[]>([]);
|
||||
const [filtered, setFiltered] = useState<Track[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setQueue } = usePlayer();
|
||||
const { toggleLike, isLiked } = useLikes();
|
||||
|
||||
useEffect(() => {
|
||||
getTracks().then(t => { setAll(t); setFiltered(t); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.toLowerCase();
|
||||
setFiltered(q ? all.filter(t =>
|
||||
t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q)
|
||||
) : all);
|
||||
}, [query, all]);
|
||||
|
||||
const playTrack = (idx: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setQueue(filtered, idx);
|
||||
usePlayer.getState().setExpanded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Библиотека</Text>
|
||||
<Text style={styles.count}>{all.length} треков</Text>
|
||||
</View>
|
||||
|
||||
{/* Search */}
|
||||
<View style={styles.searchWrap}>
|
||||
<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
|
||||
style={styles.searchInput}
|
||||
placeholder="Поиск треков..."
|
||||
placeholderTextColor={Colors.text3}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
returnKeyType="search"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{!!query && (
|
||||
<Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}>
|
||||
<Text style={{ color: Colors.text3, fontSize: 12 }}>✕</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={filtered}
|
||||
keyExtractor={t => String(t.id)}
|
||||
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item, index }) => (
|
||||
<TrackItem
|
||||
track={item}
|
||||
index={index}
|
||||
liked={isLiked(item.id)}
|
||||
onPress={() => playTrack(index)}
|
||||
onLike={() => toggleLike(item.id)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackItem({ track, index, liked, onPress, onLike }: {
|
||||
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
|
||||
}) {
|
||||
const { queue, index: qi } = usePlayer();
|
||||
const active = queue[qi]?.id === track.id;
|
||||
const cover = coverUrl(track.cover_url);
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(Math.min(index * 25, 300)).duration(300)}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.row, active && styles.rowActive]}
|
||||
>
|
||||
<Text style={styles.num}>{index + 1}</Text>
|
||||
<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}>{track.title}</Text>
|
||||
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||
</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>
|
||||
</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: '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, flex: 1 },
|
||||
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: 8 },
|
||||
searchInput:{ flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11, paddingRight: Spacing.md },
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: Spacing.sm, borderRadius: Radius.sm, paddingHorizontal: 4 },
|
||||
rowActive: { backgroundColor: Colors.surface },
|
||||
num: { width: 22, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||
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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,122 +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 },
|
||||
});
|
||||
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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,125 +1,125 @@
|
|||
import { useCallback } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Alert,
|
||||
} from 'react-native';
|
||||
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Svg, { Path, Polyline } from 'react-native-svg';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { router } from 'expo-router';
|
||||
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||
import { useAuth } from '../../store';
|
||||
import { logout } from '../../hooks/useAuth';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user } = useAuth();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
Alert.alert('Выход', 'Выйти из аккаунта?', [
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{
|
||||
text: 'Выйти', style: 'destructive',
|
||||
onPress: async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
await logout();
|
||||
router.replace('/auth');
|
||||
},
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const initials = (user.username || user.email).slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{ paddingTop: insets.top + Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={styles.pageTitle}>Профиль</Text>
|
||||
|
||||
<Animated.View entering={FadeIn.duration(500)} style={styles.avatarCard}>
|
||||
<LinearGradient colors={['#1e1e1e', '#141414']} style={styles.avatarCardGrad}>
|
||||
{user.avatar_url ? (
|
||||
<Image source={{ uri: user.avatar_url }} style={styles.avatar} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarInitials}>{initials}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.displayName}>{user.username || 'Пользователь'}</Text>
|
||||
<Text style={styles.email}>{user.email}</Text>
|
||||
<View style={[styles.planBadge, user.plan !== 'free' && styles.planBadgePro]}>
|
||||
<Text style={[styles.planText, user.plan !== 'free' && styles.planTextPro]}>
|
||||
{user.plan === 'free' ? 'Free' : 'Pro'}
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Аккаунт</Text>
|
||||
<InfoRow label="Email" value={user.email} />
|
||||
<InfoRow label="Никнейм" value={user.username || '—'} />
|
||||
<InfoRow label="Тариф" value={user.plan === 'free' ? 'Бесплатный' : 'Pro'} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(200)}>
|
||||
<Pressable style={styles.logoutBtn} onPress={handleLogout}>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="16,17 21,12 16,7" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M21 12H9" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
<Text style={styles.logoutText}>Выйти из аккаунта</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.Text entering={FadeInDown.delay(300)} style={styles.version}>
|
||||
Burmalda Music · v1.0.0 · by NodeShift
|
||||
</Animated.Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>{label}</Text>
|
||||
<Text style={styles.infoValue} numberOfLines={1}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1, backgroundColor: Colors.bg },
|
||||
content: { paddingHorizontal: Spacing.md },
|
||||
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: Spacing.lg },
|
||||
avatarCard: { borderRadius: Radius.xl, overflow: 'hidden', marginBottom: Spacing.xl },
|
||||
avatarCardGrad: { padding: Spacing.xl, alignItems: 'center', gap: Spacing.sm },
|
||||
avatar: { width: 88, height: 88, borderRadius: 44, marginBottom: Spacing.sm },
|
||||
avatarPlaceholder: { width: 88, height: 88, borderRadius: 44, backgroundColor: Colors.elevated, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.sm },
|
||||
avatarInitials: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800' },
|
||||
displayName: { color: Colors.text, fontSize: Font.xl, fontWeight: '700' },
|
||||
email: { color: Colors.text2, fontSize: Font.sm },
|
||||
planBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: Radius.full, backgroundColor: Colors.elevated, marginTop: 4 },
|
||||
planBadgePro: { backgroundColor: Colors.accent + '22', borderWidth: 1, borderColor: Colors.accent + '44' },
|
||||
planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' },
|
||||
planTextPro: { color: Colors.accent },
|
||||
section: { marginBottom: Spacing.lg },
|
||||
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 },
|
||||
infoLabel: { color: Colors.text2, fontSize: Font.md },
|
||||
infoValue: { color: Colors.text, fontSize: Font.md, fontWeight: '500', maxWidth: '55%', textAlign: 'right' },
|
||||
logoutBtn: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, backgroundColor: Colors.surface, padding: Spacing.md, borderRadius: Radius.md, marginBottom: Spacing.lg },
|
||||
logoutText: { color: Colors.red, fontSize: Font.md, fontWeight: '600' },
|
||||
version: { color: Colors.text3, fontSize: Font.xs, textAlign: 'center' },
|
||||
});
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Alert,
|
||||
} from 'react-native';
|
||||
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Svg, { Path, Polyline } from 'react-native-svg';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { router } from 'expo-router';
|
||||
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||
import { useAuth } from '../../store';
|
||||
import { logout } from '../../hooks/useAuth';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user } = useAuth();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
Alert.alert('Выход', 'Выйти из аккаунта?', [
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{
|
||||
text: 'Выйти', style: 'destructive',
|
||||
onPress: async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
await logout();
|
||||
router.replace('/auth');
|
||||
},
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const initials = (user.username || user.email).slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{ paddingTop: insets.top + Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={styles.pageTitle}>Профиль</Text>
|
||||
|
||||
<Animated.View entering={FadeIn.duration(500)} style={styles.avatarCard}>
|
||||
<LinearGradient colors={['#1e1e1e', '#141414']} style={styles.avatarCardGrad}>
|
||||
{user.avatar_url ? (
|
||||
<Image source={{ uri: user.avatar_url }} style={styles.avatar} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarInitials}>{initials}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.displayName}>{user.username || 'Пользователь'}</Text>
|
||||
<Text style={styles.email}>{user.email}</Text>
|
||||
<View style={[styles.planBadge, user.plan !== 'free' && styles.planBadgePro]}>
|
||||
<Text style={[styles.planText, user.plan !== 'free' && styles.planTextPro]}>
|
||||
{user.plan === 'free' ? 'Free' : 'Pro'}
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Аккаунт</Text>
|
||||
<InfoRow label="Email" value={user.email} />
|
||||
<InfoRow label="Никнейм" value={user.username || '—'} />
|
||||
<InfoRow label="Тариф" value={user.plan === 'free' ? 'Бесплатный' : 'Pro'} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(200)}>
|
||||
<Pressable style={styles.logoutBtn} onPress={handleLogout}>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="16,17 21,12 16,7" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M21 12H9" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
<Text style={styles.logoutText}>Выйти из аккаунта</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.Text entering={FadeInDown.delay(300)} style={styles.version}>
|
||||
Burmalda Music · v1.0.0 · by NodeShift
|
||||
</Animated.Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>{label}</Text>
|
||||
<Text style={styles.infoValue} numberOfLines={1}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1, backgroundColor: Colors.bg },
|
||||
content: { paddingHorizontal: Spacing.md },
|
||||
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: Spacing.lg },
|
||||
avatarCard: { borderRadius: Radius.xl, overflow: 'hidden', marginBottom: Spacing.xl },
|
||||
avatarCardGrad: { padding: Spacing.xl, alignItems: 'center', gap: Spacing.sm },
|
||||
avatar: { width: 88, height: 88, borderRadius: 44, marginBottom: Spacing.sm },
|
||||
avatarPlaceholder: { width: 88, height: 88, borderRadius: 44, backgroundColor: Colors.elevated, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.sm },
|
||||
avatarInitials: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800' },
|
||||
displayName: { color: Colors.text, fontSize: Font.xl, fontWeight: '700' },
|
||||
email: { color: Colors.text2, fontSize: Font.sm },
|
||||
planBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: Radius.full, backgroundColor: Colors.elevated, marginTop: 4 },
|
||||
planBadgePro: { backgroundColor: Colors.accent + '22', borderWidth: 1, borderColor: Colors.accent + '44' },
|
||||
planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' },
|
||||
planTextPro: { color: Colors.accent },
|
||||
section: { marginBottom: Spacing.lg },
|
||||
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 },
|
||||
infoLabel: { color: Colors.text2, fontSize: Font.md },
|
||||
infoValue: { color: Colors.text, fontSize: Font.md, fontWeight: '500', maxWidth: '55%', textAlign: 'right' },
|
||||
logoutBtn: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, backgroundColor: Colors.surface, padding: Spacing.md, borderRadius: Radius.md, marginBottom: Spacing.lg },
|
||||
logoutText: { color: Colors.red, fontSize: Font.md, fontWeight: '600' },
|
||||
version: { color: Colors.text3, fontSize: Font.xs, textAlign: 'center' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,130 +1,130 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard } from 'react-native';
|
||||
import Animated, { FadeIn, FadeInDown } 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 * as Haptics from 'expo-haptics';
|
||||
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||
import { searchTracks, Track, coverUrl } from '../../services/api';
|
||||
import { usePlayer, useLikes } from '../../store';
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Track[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setQueue } = usePlayer();
|
||||
const { toggleLike, isLiked } = useLikes();
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) { setResults([]); setSearched(false); return; }
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
const res = await searchTracks(q.trim());
|
||||
setResults(res);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const playTrack = (index: number) => {
|
||||
Keyboard.dismiss();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setQueue(results, index);
|
||||
usePlayer.getState().setExpanded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.title}>Поиск</Text>
|
||||
|
||||
<View style={styles.inputWrap}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Треки, исполнители..."
|
||||
placeholderTextColor={Colors.text3}
|
||||
value={query}
|
||||
onChangeText={q => { setQuery(q); doSearch(q); }}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={() => doSearch(query)}
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!searched && (
|
||||
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
||||
<Text style={styles.emptyText}>Введите название трека или исполнителя</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{searched && !loading && results.length === 0 && (
|
||||
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
||||
<Text style={styles.emptyText}>Ничего не найдено</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={results}
|
||||
keyExtractor={t => String(t.id)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item, 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
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 },
|
||||
input: { color: Colors.text, fontSize: Font.md, paddingVertical: 12 },
|
||||
emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
|
||||
emptyText: { color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm },
|
||||
rowActive: { backgroundColor: Colors.surface },
|
||||
cover: { width: 48, height: 48, 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 },
|
||||
});
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard } from 'react-native';
|
||||
import Animated, { FadeIn, FadeInDown } 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 * as Haptics from 'expo-haptics';
|
||||
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||
import { searchTracks, Track, coverUrl } from '../../services/api';
|
||||
import { usePlayer, useLikes } from '../../store';
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Track[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setQueue } = usePlayer();
|
||||
const { toggleLike, isLiked } = useLikes();
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) { setResults([]); setSearched(false); return; }
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
const res = await searchTracks(q.trim());
|
||||
setResults(res);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const playTrack = (index: number) => {
|
||||
Keyboard.dismiss();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setQueue(results, index);
|
||||
usePlayer.getState().setExpanded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.title}>Поиск</Text>
|
||||
|
||||
<View style={styles.inputWrap}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Треки, исполнители..."
|
||||
placeholderTextColor={Colors.text3}
|
||||
value={query}
|
||||
onChangeText={q => { setQuery(q); doSearch(q); }}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={() => doSearch(query)}
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!searched && (
|
||||
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
||||
<Text style={styles.emptyText}>Введите название трека или исполнителя</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{searched && !loading && results.length === 0 && (
|
||||
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
||||
<Text style={styles.emptyText}>Ничего не найдено</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={results}
|
||||
keyExtractor={t => String(t.id)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item, 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
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 },
|
||||
input: { color: Colors.text, fontSize: Font.md, paddingVertical: 12 },
|
||||
emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
|
||||
emptyText: { color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm },
|
||||
rowActive: { backgroundColor: Colors.surface },
|
||||
cover: { width: 48, height: 48, 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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,40 +1,40 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import * as Updates from 'expo-updates';
|
||||
import { useBootAuth } from '../hooks/useAuth';
|
||||
import { Colors } from '../constants/theme';
|
||||
|
||||
export default function RootLayout() {
|
||||
useBootAuth();
|
||||
useOTAUpdates();
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
|
||||
<StatusBar style="light" />
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.bg } }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="auth" options={{ presentation: 'fullScreenModal' }} />
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
function useOTAUpdates() {
|
||||
useEffect(() => {
|
||||
if (__DEV__) return;
|
||||
(async () => {
|
||||
try {
|
||||
const check = await Updates.checkForUpdateAsync();
|
||||
if (check.isAvailable) {
|
||||
await Updates.fetchUpdateAsync();
|
||||
await Updates.reloadAsync();
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {
|
||||
// silent fail
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
import { useEffect } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import * as Updates from 'expo-updates';
|
||||
import { useBootAuth } from '../hooks/useAuth';
|
||||
import { Colors } from '../constants/theme';
|
||||
|
||||
export default function RootLayout() {
|
||||
useBootAuth();
|
||||
useOTAUpdates();
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
|
||||
<StatusBar style="light" />
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.bg } }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="auth" options={{ presentation: 'fullScreenModal' }} />
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
function useOTAUpdates() {
|
||||
useEffect(() => {
|
||||
if (__DEV__) return;
|
||||
(async () => {
|
||||
try {
|
||||
const check = await Updates.checkForUpdateAsync();
|
||||
if (check.isAvailable) {
|
||||
await Updates.fetchUpdateAsync();
|
||||
await Updates.reloadAsync();
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {
|
||||
// silent fail
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
|
|
|
|||
330
app/auth.tsx
330
app/auth.tsx
|
|
@ -1,165 +1,165 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, ActivityIndicator, Dimensions,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue, useAnimatedStyle, withSpring, withTiming,
|
||||
FadeIn, SlideInDown,
|
||||
} from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import * as Linking from 'expo-linking';
|
||||
import { router } from 'expo-router';
|
||||
import Svg, { Path, Circle, Polyline, Line } from 'react-native-svg';
|
||||
import { Colors, Font, Radius, Spacing } from '../constants/theme';
|
||||
import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api';
|
||||
import { useAuth } from '../store';
|
||||
import { saveUser } from '../hooks/useAuth';
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
export default function AuthScreen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { setUser } = useAuth();
|
||||
const btnScale = useSharedValue(1);
|
||||
|
||||
const btnStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: btnScale.value }],
|
||||
}));
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
||||
const url = buildOAuthUrl(state);
|
||||
const result = await WebBrowser.openAuthSessionAsync(url, 'burmalda://auth/callback');
|
||||
|
||||
if (result.type !== 'success') {
|
||||
setError('Авторизация отменена');
|
||||
return;
|
||||
}
|
||||
const parsed = Linking.parse(result.url);
|
||||
const code = parsed.queryParams?.code as string | undefined;
|
||||
if (!code) {
|
||||
setError((parsed.queryParams?.error as string) || 'Ошибка авторизации');
|
||||
return;
|
||||
}
|
||||
const data = await exchangeCode(code);
|
||||
await saveTokens(data.access_token, data.refresh_token);
|
||||
await saveUser(data.user);
|
||||
setUser(data.user);
|
||||
router.replace('/(tabs)');
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Что-то пошло не так');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setUser]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Blob color={Colors.accent} size={420} left={-80} top={-100} delay={0} />
|
||||
<Blob color="#6c63ff" size={320} left={width - 200} top={height * 0.55} delay={2000} />
|
||||
<Blob color={Colors.accent} size={240} left={width * 0.3} top={height - 100} delay={4000} />
|
||||
|
||||
<Animated.View style={styles.card} entering={FadeIn.duration(600)}>
|
||||
<Animated.View entering={SlideInDown.delay(100).springify()} style={styles.logoWrap}>
|
||||
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.logoIcon}>
|
||||
<Svg width={38} height={38} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M9 18V5l12-2v13" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||
<Circle cx={6} cy={18} r={3} fill="#fff" />
|
||||
<Circle cx={18} cy={16} r={3} fill="#fff" />
|
||||
</Svg>
|
||||
</LinearGradient>
|
||||
<Text style={styles.logoTitle}>Burmalda Music</Text>
|
||||
<Text style={styles.logoSub}>by NodeShift</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={SlideInDown.delay(200).springify()} style={{ width: '100%' }}>
|
||||
<Animated.View style={btnStyle}>
|
||||
<Pressable
|
||||
style={styles.btnWrap}
|
||||
disabled={loading}
|
||||
onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 14 }); }}
|
||||
onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }}
|
||||
onPress={handleLogin}
|
||||
>
|
||||
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.btn}>
|
||||
{loading
|
||||
? <ActivityIndicator color="#fff" size="small" />
|
||||
: (
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="8 17 12 13 16 17" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Line x1="12" y1="13" x2="12" y2="21" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
)}
|
||||
<Text style={styles.btnText}>
|
||||
{loading ? 'Ожидаем авторизацию…' : 'Войти через NodeShift'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
{!!error && (
|
||||
<Animated.Text entering={FadeIn} style={styles.error}>{error}</Animated.Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.Text entering={FadeIn.delay(300)} style={styles.footer}>
|
||||
Нажимая «Войти», вы соглашаетесь с{'\n'}условиями использования NodeShift
|
||||
</Animated.Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Blob({ color, size, left, top, delay }: {
|
||||
color: string; size: number; left: number; top: number; delay: number;
|
||||
}) {
|
||||
const tx = useSharedValue(0);
|
||||
const ty = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
tx.value = withTiming(25, { duration: 8000 }, () =>
|
||||
tx.value = withTiming(-20, { duration: 8000 }, () =>
|
||||
tx.value = withTiming(0, { duration: 8000 }, animate)));
|
||||
ty.value = withTiming(-35, { duration: 8000 }, () =>
|
||||
ty.value = withTiming(18, { duration: 8000 }, () =>
|
||||
ty.value = withTiming(0, { duration: 8000 })));
|
||||
};
|
||||
const t = setTimeout(animate, delay);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: tx.value }, { translateY: ty.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.blob, style, {
|
||||
width: size, height: size, borderRadius: size / 2,
|
||||
backgroundColor: color, left, top,
|
||||
}]} />
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
||||
blob: { position: 'absolute', opacity: 0.12 },
|
||||
card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 },
|
||||
logoWrap: { alignItems: 'center', marginBottom: 40 },
|
||||
logoIcon: { width: 84, height: 84, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.md, shadowColor: Colors.accent, shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 },
|
||||
logoTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: 4 },
|
||||
logoSub: { color: Colors.text3, fontSize: Font.sm, fontWeight: '500' },
|
||||
btnWrap: { width: '100%', borderRadius: Radius.md, overflow: 'hidden', shadowColor: Colors.accent, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 },
|
||||
btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 },
|
||||
btnText: { color: '#fff', fontSize: Font.md, fontWeight: '700' },
|
||||
error: { color: Colors.red, fontSize: Font.sm, textAlign: 'center', marginTop: Spacing.sm },
|
||||
footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 },
|
||||
});
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, ActivityIndicator, Dimensions,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue, useAnimatedStyle, withSpring, withTiming,
|
||||
FadeIn, SlideInDown,
|
||||
} from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import * as Linking from 'expo-linking';
|
||||
import { router } from 'expo-router';
|
||||
import Svg, { Path, Circle, Polyline, Line } from 'react-native-svg';
|
||||
import { Colors, Font, Radius, Spacing } from '../constants/theme';
|
||||
import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api';
|
||||
import { useAuth } from '../store';
|
||||
import { saveUser } from '../hooks/useAuth';
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
export default function AuthScreen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { setUser } = useAuth();
|
||||
const btnScale = useSharedValue(1);
|
||||
|
||||
const btnStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: btnScale.value }],
|
||||
}));
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
||||
const url = buildOAuthUrl(state);
|
||||
const result = await WebBrowser.openAuthSessionAsync(url, 'burmalda://auth/callback');
|
||||
|
||||
if (result.type !== 'success') {
|
||||
setError('Авторизация отменена');
|
||||
return;
|
||||
}
|
||||
const parsed = Linking.parse(result.url);
|
||||
const code = parsed.queryParams?.code as string | undefined;
|
||||
if (!code) {
|
||||
setError((parsed.queryParams?.error as string) || 'Ошибка авторизации');
|
||||
return;
|
||||
}
|
||||
const data = await exchangeCode(code);
|
||||
await saveTokens(data.access_token, data.refresh_token);
|
||||
await saveUser(data.user);
|
||||
setUser(data.user);
|
||||
router.replace('/(tabs)');
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Что-то пошло не так');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setUser]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Blob color={Colors.accent} size={420} left={-80} top={-100} delay={0} />
|
||||
<Blob color="#6c63ff" size={320} left={width - 200} top={height * 0.55} delay={2000} />
|
||||
<Blob color={Colors.accent} size={240} left={width * 0.3} top={height - 100} delay={4000} />
|
||||
|
||||
<Animated.View style={styles.card} entering={FadeIn.duration(600)}>
|
||||
<Animated.View entering={SlideInDown.delay(100).springify()} style={styles.logoWrap}>
|
||||
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.logoIcon}>
|
||||
<Svg width={38} height={38} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M9 18V5l12-2v13" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||
<Circle cx={6} cy={18} r={3} fill="#fff" />
|
||||
<Circle cx={18} cy={16} r={3} fill="#fff" />
|
||||
</Svg>
|
||||
</LinearGradient>
|
||||
<Text style={styles.logoTitle}>Burmalda Music</Text>
|
||||
<Text style={styles.logoSub}>by NodeShift</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={SlideInDown.delay(200).springify()} style={{ width: '100%' }}>
|
||||
<Animated.View style={btnStyle}>
|
||||
<Pressable
|
||||
style={styles.btnWrap}
|
||||
disabled={loading}
|
||||
onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 14 }); }}
|
||||
onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }}
|
||||
onPress={handleLogin}
|
||||
>
|
||||
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.btn}>
|
||||
{loading
|
||||
? <ActivityIndicator color="#fff" size="small" />
|
||||
: (
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="8 17 12 13 16 17" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Line x1="12" y1="13" x2="12" y2="21" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
)}
|
||||
<Text style={styles.btnText}>
|
||||
{loading ? 'Ожидаем авторизацию…' : 'Войти через NodeShift'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
{!!error && (
|
||||
<Animated.Text entering={FadeIn} style={styles.error}>{error}</Animated.Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.Text entering={FadeIn.delay(300)} style={styles.footer}>
|
||||
Нажимая «Войти», вы соглашаетесь с{'\n'}условиями использования NodeShift
|
||||
</Animated.Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Blob({ color, size, left, top, delay }: {
|
||||
color: string; size: number; left: number; top: number; delay: number;
|
||||
}) {
|
||||
const tx = useSharedValue(0);
|
||||
const ty = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
tx.value = withTiming(25, { duration: 8000 }, () =>
|
||||
tx.value = withTiming(-20, { duration: 8000 }, () =>
|
||||
tx.value = withTiming(0, { duration: 8000 }, animate)));
|
||||
ty.value = withTiming(-35, { duration: 8000 }, () =>
|
||||
ty.value = withTiming(18, { duration: 8000 }, () =>
|
||||
ty.value = withTiming(0, { duration: 8000 })));
|
||||
};
|
||||
const t = setTimeout(animate, delay);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: tx.value }, { translateY: ty.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.blob, style, {
|
||||
width: size, height: size, borderRadius: size / 2,
|
||||
backgroundColor: color, left, top,
|
||||
}]} />
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
||||
blob: { position: 'absolute', opacity: 0.12 },
|
||||
card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 },
|
||||
logoWrap: { alignItems: 'center', marginBottom: 40 },
|
||||
logoIcon: { width: 84, height: 84, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.md, shadowColor: Colors.accent, shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 },
|
||||
logoTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: 4 },
|
||||
logoSub: { color: Colors.text3, fontSize: Font.sm, fontWeight: '500' },
|
||||
btnWrap: { width: '100%', borderRadius: Radius.md, overflow: 'hidden', shadowColor: Colors.accent, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 },
|
||||
btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 },
|
||||
btnText: { color: '#fff', fontSize: Font.md, fontWeight: '700' },
|
||||
error: { color: Colors.red, fontSize: Font.sm, textAlign: 'center', marginTop: Spacing.sm },
|
||||
footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue