Add files via upload

This commit is contained in:
Savsis 2026-04-28 16:54:42 +05:00 • committed by GitHub
parent 81ce3b2271
commit 9fb037a648
13 changed files with 832 additions and 518 deletions

View file

@ -4,23 +4,26 @@ 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 } from '../../store';
import { useAuth, usePlayer, useLikes } from '../../store';
import MiniPlayer from '../../components/MiniPlayer';
import Player from '../../components/Player';
export default function TabsLayout() {
const { user, ready } = useAuth();
const { expanded } = usePlayer();
const { loadLikes } = useLikes();
useEffect(() => {
if (ready && !user) {
router.replace('/auth');
}
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="#1db954" />
<ActivityIndicator color={Colors.accent} />
</View>
);
if (!user) return null;
@ -32,18 +35,19 @@ export default function TabsLayout() {
headerShown: false,
tabBarStyle: styles.tabBar,
tabBarBackground: () => (
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
),
tabBarActiveTintColor: Colors.green,
tabBarActiveTintColor: Colors.text,
tabBarInactiveTintColor: Colors.text3,
tabBarLabelStyle: { fontSize: 10, fontWeight: '600', marginBottom: 4 },
tabBarItemStyle: { paddingTop: 8 },
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="profile" options={{ title: 'Профиль', tabBarIcon: ({ color }) => <ProfileIcon color={color} /> }} />
<Tabs.Screen name="index" options={{ title: 'Главная', tabBarIcon: ({ color }) => <HomeIcon color={color} /> }} />
<Tabs.Screen name="library" options={{ title: 'Библиотека', tabBarIcon: ({ color }) => <LibIcon color={color} /> }} />
<Tabs.Screen name="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 />
@ -54,35 +58,39 @@ export default function TabsLayout() {
function HomeIcon({ color }: { color: string }) {
return (
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
<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={22} height={22} viewBox="0 0 24 24" fill="none">
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M9 18V5l12-2v13" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
<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={22} height={22} viewBox="0 0 24 24" fill="none">
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Circle cx={11} cy={11} r={8} stroke={color} strokeWidth={1.8} />
<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={22} height={22} viewBox="0 0 24 24" fill="none">
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
<Circle cx={12} cy={7} r={4} stroke={color} strokeWidth={1.8} />
</Svg>
@ -92,7 +100,8 @@ function ProfileIcon({ color }: { color: string }) {
const styles = StyleSheet.create({
tabBar: {
position: 'absolute',
borderTopWidth: 0,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(255,255,255,0.08)',
backgroundColor: 'transparent',
elevation: 0,
height: TAB_BAR_HEIGHT,

View file

@ -1,26 +1,24 @@
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 { 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 { 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 { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
import { usePlayer } from '../../store';
import { usePlayer, useLikes } from '../../store';
const { width } = Dimensions.get('window');
const CARD_W = width * 0.42;
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, queue } = usePlayer();
const { setQueue } = usePlayer();
const { toggleLike, isLiked } = useLikes();
const load = useCallback(async () => {
const [t, a] = await Promise.all([getTracks(), getAlbums()]);
@ -36,48 +34,52 @@ export default function HomeScreen() {
setRefresh(false);
};
const playTrack = (track: Track, index: number) => {
setQueue(tracks, index);
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 + Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }
]}
refreshControl={
<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.green} />
}
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}
>
{/* Header */}
<Animated.View entering={FadeIn.duration(500)}>
<Text style={styles.greeting}>Добро пожаловать</Text>
<Text style={styles.title}>NodeShift Music</Text>
<Animated.View entering={FadeIn.duration(400)} style={styles.pageHeader}>
<Text style={styles.pageTitle}>NodeShift Music</Text>
</Animated.View>
{/* Albums row */}
{/* Now playing strip (if track exists) */}
<NowPlayingStrip />
{/* Albums */}
{albums.length > 0 && (
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
<Text style={styles.sectionTitle}>Альбомы</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hScroll}>
<Text style={styles.secTitle}>Альбомы</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hRow}>
{albums.map((a, i) => (
<AlbumCard key={a.id} album={a} index={i}
onPress={() => {/* open album */}} />
<AlbumCard key={a.id} album={a} delay={i * 50} />
))}
</ScrollView>
</Animated.View>
)}
{/* Tracks list */}
{/* Tracks */}
{tracks.length > 0 && (
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
<Text style={styles.sectionTitle}>Треки</Text>
<Text style={styles.secTitle}>Треки</Text>
{tracks.map((t, i) => (
<TrackRow key={t.id} track={t} index={i} onPress={() => playTrack(t, i)} />
<TrackRow
key={t.id}
track={t}
index={i}
liked={isLiked(t.id)}
onPress={() => playTrack(t, tracks, i)}
onLike={() => toggleLike(t.id)}
/>
))}
</Animated.View>
)}
@ -85,17 +87,45 @@ export default function HomeScreen() {
);
}
function AlbumCard({ album, index, onPress }: { album: Album; index: number; onPress: () => void }) {
function NowPlayingStrip() {
const { queue, index, playing, setExpanded } = usePlayer();
const track = queue[index];
if (!track) return null;
const cover = coverUrl(track.cover_url);
return (
<Animated.View entering={FadeInDown.duration(300)} style={styles.npStrip}>
{cover && <Image source={{ uri: cover }} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={40} />}
<View style={[StyleSheet.absoluteFill, styles.npDim]} />
<Pressable style={styles.npContent} onPress={() => setExpanded(true)}>
{cover
? <Image source={{ uri: cover }} style={styles.npCover} contentFit="cover" />
: <View style={[styles.npCover, { backgroundColor: Colors.elevated }]} />
}
<View style={styles.npInfo}>
<Text style={styles.npLabel}>Сейчас играет</Text>
<Text style={styles.npTitle} numberOfLines={1}>{track.title}</Text>
<Text style={styles.npArtist} numberOfLines={1}>{track.artist}</Text>
</View>
<View style={styles.npDot}>
{playing
? <View style={styles.npEq}>{[1,2,3].map(i => <Animated.View key={i} style={[styles.npBar, { height: 4 + i * 4 }]} />)}</View>
: <Svg width={16} height={16} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>
}
</View>
</Pressable>
</Animated.View>
);
}
function AlbumCard({ album, delay }: { album: Album; delay: number }) {
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const cover = coverUrl(album.cover_url);
return (
<Animated.View entering={FadeInDown.delay(index * 60)} style={style}>
<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}
>
<Image
@ -111,62 +141,71 @@ function AlbumCard({ album, index, onPress }: { album: Album; index: number; onP
);
}
function TrackRow({ track, index, onPress }: { track: Track; index: number; onPress: () => void }) {
function TrackRow({ track, index, liked, onPress, onLike }: {
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
}) {
const { queue, index: qi } = usePlayer();
const isPlaying = queue[qi]?.id === track.id;
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const cover = coverUrl(track.cover_url);
const active = queue[qi]?.id === track.id;
const cover = coverUrl(track.cover_url);
return (
<Animated.View entering={FadeInDown.delay(index * 40).duration(300)} style={style}>
<Pressable
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
onPress={onPress}
style={[styles.trackRow, isPlaying && styles.trackRowActive]}
>
<Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={styles.trackCover}
contentFit="cover"
transition={200}
/>
<View style={styles.trackInfo}>
<Text style={[styles.trackTitle, isPlaying && { color: Colors.green }]} numberOfLines={1}>
{track.title}
</Text>
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
</View>
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
<Text style={styles.trackNum}>{index + 1}</Text>
<Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={styles.trackCover}
contentFit="cover"
transition={200}
/>
<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>
</Animated.View>
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
</Pressable>
);
}
function fmt(s: number) {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
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 },
greeting: { color: Colors.text3, fontSize: Font.sm, fontWeight: '600', marginBottom: 4 },
title: { color: Colors.text, fontSize: Font.xxxl, fontWeight: '800', letterSpacing: -1, marginBottom: Spacing.xl },
section: { marginBottom: Spacing.xl },
sectionTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
hScroll: { paddingRight: Spacing.md, gap: Spacing.md },
albumCard: { width: CARD_W },
albumCover: { width: CARD_W, height: CARD_W, borderRadius: Radius.md, marginBottom: Spacing.sm, backgroundColor: Colors.elevated },
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: Spacing.sm, borderRadius: Radius.md, gap: Spacing.md },
trackRowActive: { backgroundColor: Colors.surface },
trackCover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
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 },
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 },
albumCover: { width: CARD_W, height: CARD_W, borderRadius: Radius.md, backgroundColor: Colors.elevated, marginBottom: Spacing.sm },
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
albumArtist: { color: Colors.text2, 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' },
trackCover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
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.55)' },
npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
npCover: { width: 52, height: 52, borderRadius: Radius.sm },
npInfo: { flex: 1 },
npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 },
npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 },
npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 },
});

View file

@ -1,14 +1,13 @@
import { useEffect, useState, useCallback } from 'react';
import {
View, Text, FlatList, StyleSheet, TextInput, Pressable,
} from 'react-native';
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 } from 'react-native-svg';
import Svg, { Circle, Line, Path } from 'react-native-svg';
import * as Haptics from 'expo-haptics';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
import { getTracks, Track, coverUrl } from '../../services/api';
import { usePlayer } from '../../store';
import { usePlayer, useLikes } from '../../store';
export default function LibraryScreen() {
const [all, setAll] = useState<Track[]>([]);
@ -16,6 +15,7 @@ export default function LibraryScreen() {
const [query, setQuery] = useState('');
const insets = useSafeAreaInsets();
const { setQueue } = usePlayer();
const { toggleLike, isLiked } = useLikes();
useEffect(() => {
getTracks().then(t => { setAll(t); setFiltered(t); });
@ -28,13 +28,15 @@ export default function LibraryScreen() {
) : all);
}, [query, all]);
const playTrack = (track: Track, index: number) => {
setQueue(filtered, index);
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>
@ -42,7 +44,10 @@ export default function LibraryScreen() {
{/* Search */}
<View style={styles.searchWrap}>
<SearchSvg />
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none" style={{ marginLeft: 12 }}>
<Circle cx={11} cy={11} r={8} stroke={Colors.text3} strokeWidth={1.8} />
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" />
</Svg>
<TextInput
style={styles.searchInput}
placeholder="Поиск треков..."
@ -53,8 +58,8 @@ export default function LibraryScreen() {
autoCorrect={false}
/>
{!!query && (
<Pressable onPress={() => setQuery('')} hitSlop={12}>
<Text style={styles.clearBtn}>✕</Text>
<Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}>
<Text style={{ color: Colors.text3, fontSize: 12 }}>✕</Text>
</Pressable>
)}
</View>
@ -62,40 +67,36 @@ export default function LibraryScreen() {
<FlatList
data={filtered}
keyExtractor={t => String(t.id)}
contentContainerStyle={{
paddingHorizontal: Spacing.md,
paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24,
}}
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackItem track={item} index={index} onPress={() => playTrack(item, index)} />
<TrackItem
track={item}
index={index}
liked={isLiked(item.id)}
onPress={() => playTrack(index)}
onLike={() => toggleLike(item.id)}
/>
)}
ItemSeparatorComponent={() => <View style={styles.sep} />}
/>
</View>
);
}
function TrackItem({ track, index, onPress }: { track: Track; index: number; onPress: () => void }) {
function TrackItem({ track, index, liked, onPress, onLike }: {
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
}) {
const { queue, index: qi } = usePlayer();
const active = queue[qi]?.id === track.id;
const scale = useSharedValue(1);
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const cover = coverUrl(track.cover_url);
const cover = coverUrl(track.cover_url);
return (
<Animated.View entering={FadeInDown.delay(Math.min(index * 30, 300)).duration(300)} style={aStyle}>
<Animated.View entering={FadeInDown.delay(Math.min(index * 25, 300)).duration(300)}>
<Pressable
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
onPress={onPress}
style={[styles.row, active && styles.rowActive]}
>
<View style={styles.numWrap}>
{active
? <View style={styles.eqDots}>{[0,1,2].map(i => <EqDot key={i} delay={i * 150} />)}</View>
: <Text style={styles.num}>{index + 1}</Text>}
</View>
<Text style={styles.num}>{index + 1}</Text>
<Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={styles.cover}
@ -103,65 +104,37 @@ function TrackItem({ track, index, onPress }: { track: Track; index: number; onP
transition={200}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.trackTitle, active && { color: Colors.green }]} numberOfLines={1}>
{track.title}
</Text>
<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 EqDot({ delay }: { delay: number }) {
const h = useSharedValue(4);
const style = useAnimatedStyle(() => ({ height: h.value }));
useEffect(() => {
const loop = () => {
h.value = withSpring(12 + Math.random() * 8, { damping: 6 }, () => {
h.value = withSpring(4, { damping: 6 }, loop);
});
};
const t = setTimeout(loop, delay);
return () => clearTimeout(t);
}, []);
return <Animated.View style={[styles.dot, style]} />;
}
function SearchSvg() {
return (
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" style={{ marginLeft: 12 }}>
<Circle cx={11} cy={11} r={8} stroke={Colors.text3} strokeWidth={1.8} />
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" />
</Svg>
);
}
function fmt(s: number) {
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: { paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm, flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm },
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
count: { color: Colors.text3, fontSize: Font.sm },
searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: Spacing.sm },
searchInput: { flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 12, paddingRight: Spacing.md },
clearBtn: { color: Colors.text3, fontSize: Font.sm, paddingRight: 12 },
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderRadius: Radius.sm, gap: Spacing.md },
rowActive: { backgroundColor: Colors.surface + '80' },
numWrap: { width: 28, alignItems: 'center' },
num: { color: Colors.text3, fontSize: Font.sm },
eqDots: { flexDirection: 'row', alignItems: 'flex-end', gap: 2, height: 16 },
dot: { width: 3, backgroundColor: Colors.green, borderRadius: 2 },
cover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
dur: { color: Colors.text3, fontSize: Font.xs, paddingRight: 4 },
sep: { height: 1 },
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 },
});

122
app/(tabs)/liked.tsx Normal file
View file

@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import { View, Text, FlatList, StyleSheet, Pressable } from 'react-native';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Image } from 'expo-image';
import Svg, { Path } from 'react-native-svg';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
import { getTracks, Track, coverUrl } from '../../services/api';
import { usePlayer, useLikes } from '../../store';
export default function LikedScreen() {
const [all, setAll] = useState<Track[]>([]);
const insets = useSafeAreaInsets();
const { setQueue } = usePlayer();
const { likes, toggleLike } = useLikes();
useEffect(() => {
getTracks().then(setAll);
}, []);
const liked = all.filter(t => likes.has(t.id));
const playTrack = (track: Track, idx: number) => {
setQueue(liked, idx);
usePlayer.getState().setExpanded(true);
};
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.header}>
<Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.pink}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={Colors.pink} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
<Text style={styles.title}>Понравилось</Text>
<Text style={styles.count}>{liked.length} треков</Text>
</View>
{liked.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>♡</Text>
<Text style={styles.emptyTitle}>Пусто</Text>
<Text style={styles.emptyText}>Нажмите ♡ у трека, чтобы добавить его сюда</Text>
</View>
) : (
<FlatList
data={liked}
keyExtractor={t => String(t.id)}
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<LikedRow
track={item}
index={index}
onPress={() => playTrack(item, index)}
onUnlike={() => toggleLike(item.id)}
/>
)}
/>
)}
</View>
);
}
function LikedRow({ track, index, onPress, onUnlike }: {
track: Track; index: number; onPress: () => void; onUnlike: () => void;
}) {
const { queue, index: qi } = usePlayer();
const active = queue[qi]?.id === track.id;
const scale = useSharedValue(1);
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const cover = coverUrl(track.cover_url);
return (
<Animated.View entering={FadeInDown.delay(Math.min(index * 40, 400)).duration(300)} style={aStyle}>
<Pressable
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
onPress={onPress}
style={[styles.row, active && styles.rowActive]}
>
<Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={styles.cover}
contentFit="cover"
transition={200}
/>
<View style={styles.info}>
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
</View>
<Pressable onPress={onUnlike} hitSlop={12} style={styles.likeBtn}>
<Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.pink}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={Colors.pink} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
</Pressable>
</Pressable>
</Animated.View>
);
}
function fmt(s: number) {
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.bg },
header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.md },
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 },
count: { color: Colors.text3, fontSize: Font.sm },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
emptyIcon: { fontSize: 52, color: Colors.text3, marginBottom: 16 },
emptyTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: 8 },
emptyText: { color: Colors.text2, fontSize: Font.sm, textAlign: 'center', lineHeight: 20 },
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm },
rowActive: { backgroundColor: Colors.surface + '80' },
cover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
info: { flex: 1 },
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
likeBtn: { padding: 4 },
});

View file

@ -111,9 +111,9 @@ const styles = StyleSheet.create({
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.green + '22', borderWidth: 1, borderColor: Colors.green + '44' },
planBadgePro: { backgroundColor: Colors.accent + '22', borderWidth: 1, borderColor: Colors.accent + '44' },
planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' },
planTextPro: { color: Colors.green },
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 },

View file

@ -1,13 +1,13 @@
import { useState, useCallback } from 'react';
import {
View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard,
} from 'react-native';
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
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 } from '../../store';
import { usePlayer, useLikes } from '../../store';
export default function SearchScreen() {
const [query, setQuery] = useState('');
@ -16,6 +16,7 @@ export default function SearchScreen() {
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; }
@ -28,6 +29,7 @@ export default function SearchScreen() {
const playTrack = (index: number) => {
Keyboard.dismiss();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setQueue(results, index);
usePlayer.getState().setExpanded(true);
};
@ -52,14 +54,12 @@ export default function SearchScreen() {
{!searched && (
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
<Text style={styles.emptyIcon}>🎵</Text>
<Text style={styles.emptyText}>Введите название трека{'\n'}или имя исполнителя</Text>
<Text style={styles.emptyText}>Введите название трека или исполнителя</Text>
</Animated.View>
)}
{searched && !loading && results.length === 0 && (
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
<Text style={styles.emptyIcon}>🔍</Text>
<Text style={styles.emptyText}>Ничего не найдено</Text>
</Animated.View>
)}
@ -68,69 +68,63 @@ export default function SearchScreen() {
data={results}
keyExtractor={t => String(t.id)}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
paddingHorizontal: Spacing.md,
paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24,
}}
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<SearchResult track={item} index={index} onPress={() => playTrack(index)} />
<Animated.View entering={FadeInDown.delay(index * 30).duration(250)}>
<Pressable
onPress={() => playTrack(index)}
style={[styles.row, usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id && styles.rowActive]}
>
{(() => {
const cover = coverUrl(item.cover_url);
const liked = isLiked(item.id);
const active = usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id;
return (
<>
<Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={styles.cover}
contentFit="cover"
transition={200}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{item.title}</Text>
<Text style={styles.artist} numberOfLines={1}>{item.artist}</Text>
</View>
<Pressable onPress={() => toggleLike(item.id)} hitSlop={12}>
<Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
</Pressable>
<Text style={styles.dur}>{fmt(item.duration)}</Text>
</>
);
})()}
</Pressable>
</Animated.View>
)}
/>
</View>
);
}
function SearchResult({ track, index, onPress }: { track: Track; index: number; onPress: () => void }) {
const { queue, index: qi } = usePlayer();
const active = queue[qi]?.id === track.id;
const scale = useSharedValue(1);
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const cover = coverUrl(track.cover_url);
return (
<Animated.View entering={FadeInDown.delay(index * 40).duration(250)} style={aStyle}>
<Pressable
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
onPress={onPress}
style={[styles.row, active && styles.rowActive]}
>
<Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={styles.cover}
contentFit="cover"
transition={200}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.trackTitle, active && { color: Colors.green }]} numberOfLines={1}>
{track.title}
</Text>
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
</View>
<Text style={styles.dur}>{fmt(track.duration)}</Text>
</Pressable>
</Animated.View>
);
}
function fmt(s: number) {
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: 13 },
emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
emptyIcon: { fontSize: 48, marginBottom: Spacing.md },
emptyText: { color: Colors.text2, fontSize: Font.md, textAlign: 'center', lineHeight: 24 },
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 },
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 },
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 },
});

View file

@ -31,6 +31,7 @@ function useOTAUpdates() {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
// eslint-disable-next-line no-empty
} catch {
// silent fail
}