diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 55fd7d2..2125e44 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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 ( - + ); if (!user) return null; @@ -32,18 +35,19 @@ export default function TabsLayout() { headerShown: false, tabBarStyle: styles.tabBar, tabBarBackground: () => ( - + ), - 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 }, }} > - }} /> - }} /> - }} /> - }} /> + }} /> + }} /> + }} /> + }} /> + }} /> @@ -54,35 +58,39 @@ export default function TabsLayout() { function HomeIcon({ color }: { color: string }) { return ( - + ); } - function LibIcon({ color }: { color: string }) { return ( - + ); } - function SearchIcon({ color }: { color: string }) { return ( - + ); } - +function HeartIcon({ color }: { color: string }) { + return ( + + + + ); +} function ProfileIcon({ color }: { color: string }) { return ( - + @@ -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, diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 03c1f88..3bc359a 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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([]); const [albums, setAlbums] = useState([]); 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 ( - } + contentContainerStyle={[styles.content, { paddingTop: insets.top + 14, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }]} + refreshControl={} showsVerticalScrollIndicator={false} > {/* Header */} - - Добро пожаловать - NodeShift Music + + NodeShift Music - {/* Albums row */} + {/* Now playing strip (if track exists) */} + + + {/* Albums */} {albums.length > 0 && ( - Альбомы - + Альбомы + {albums.map((a, i) => ( - {/* open album */}} /> + ))} )} - {/* Tracks list */} + {/* Tracks */} {tracks.length > 0 && ( - Треки + Треки {tracks.map((t, i) => ( - playTrack(t, i)} /> + playTrack(t, tracks, i)} + onLike={() => toggleLike(t.id)} + /> ))} )} @@ -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 ( + + {cover && } + + setExpanded(true)}> + {cover + ? + : + } + + Сейчас играет + {track.title} + {track.artist} + + + {playing + ? {[1,2,3].map(i => )} + : + } + + + + ); +} + +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 ( - + { scale.value = withSpring(0.95, { damping: 15 }); }} onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} - onPress={onPress} style={styles.albumCard} > 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 ( - - { scale.value = withSpring(0.97, { damping: 15 }); }} - onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} - onPress={onPress} - style={[styles.trackRow, isPlaying && styles.trackRowActive]} - > - - - - {track.title} - - {track.artist} - - {fmt(track.duration)} + + {index + 1} + + + {track.title} + {track.artist} + + + + + - + {fmt(track.duration)} + ); } 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 }, }); diff --git a/app/(tabs)/library.tsx b/app/(tabs)/library.tsx index dee5f99..13e8429 100644 --- a/app/(tabs)/library.tsx +++ b/app/(tabs)/library.tsx @@ -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([]); @@ -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 ( + {/* Header */} Библиотека {all.length} треков @@ -42,7 +44,10 @@ export default function LibraryScreen() { {/* Search */} - + + + + {!!query && ( - setQuery('')} hitSlop={12}> - ✕ + setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}> + ✕ )} @@ -62,40 +67,36 @@ export default function LibraryScreen() { 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 }) => ( - playTrack(item, index)} /> + playTrack(index)} + onLike={() => toggleLike(item.id)} + /> )} - ItemSeparatorComponent={() => } /> ); } -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 ( - + { scale.value = withSpring(0.97, { damping: 15 }); }} - onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} onPress={onPress} style={[styles.row, active && styles.rowActive]} > - - {active - ? {[0,1,2].map(i => )} - : {index + 1}} - + {index + 1} - - {track.title} - + {track.title} {track.artist} + + + + + {fmt(track.duration)} ); } -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 ; -} - -function SearchSvg() { - return ( - - - - - ); -} - 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 }, }); diff --git a/app/(tabs)/liked.tsx b/app/(tabs)/liked.tsx new file mode 100644 index 0000000..ddf0c96 --- /dev/null +++ b/app/(tabs)/liked.tsx @@ -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([]); + 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 ( + + + + + + Понравилось + {liked.length} треков + + + {liked.length === 0 ? ( + + ♡ + Пусто + Нажмите ♡ у трека, чтобы добавить его сюда + + ) : ( + String(t.id)} + contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} + showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => ( + playTrack(item, index)} + onUnlike={() => toggleLike(item.id)} + /> + )} + /> + )} + + ); +} + +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 ( + + { scale.value = withSpring(0.97, { damping: 15 }); }} + onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} + onPress={onPress} + style={[styles.row, active && styles.rowActive]} + > + + + {track.title} + {track.artist} + + + + + + + + + ); +} + +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 }, +}); diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index c947eca..52dc1f9 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -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 }, diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx index 419a1ea..fe3dea4 100644 --- a/app/(tabs)/search.tsx +++ b/app/(tabs)/search.tsx @@ -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 && ( - 🎵 - Введите название трека{'\n'}или имя исполнителя + Введите название трека или исполнителя )} {searched && !loading && results.length === 0 && ( - 🔍 Ничего не найдено )} @@ -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 }) => ( - playTrack(index)} /> + + 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 ( + <> + + + {item.title} + {item.artist} + + toggleLike(item.id)} hitSlop={12}> + + + + + {fmt(item.duration)} + + ); + })()} + + )} /> ); } -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 ( - - { scale.value = withSpring(0.97, { damping: 15 }); }} - onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} - onPress={onPress} - style={[styles.row, active && styles.rowActive]} - > - - - - {track.title} - - {track.artist} - - {fmt(track.duration)} - - - ); -} - 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 }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index 28bb69b..e44583d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -31,6 +31,7 @@ function useOTAUpdates() { await Updates.fetchUpdateAsync(); await Updates.reloadAsync(); } + // eslint-disable-next-line no-empty } catch { // silent fail } diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index f6ba185..c5e1108 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -1,24 +1,24 @@ import { useCallback } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import Animated, { - useSharedValue, useAnimatedStyle, withSpring, FadeInDown, FadeOutDown, - useAnimatedGestureHandler, runOnJS, + useSharedValue, useAnimatedStyle, withSpring, + FadeInDown, FadeOutDown, useAnimatedGestureHandler, runOnJS, } from 'react-native-reanimated'; import { PanGestureHandler } from 'react-native-gesture-handler'; import { BlurView } from 'expo-blur'; import { Image } from 'expo-image'; -import Svg, { Path } from 'react-native-svg'; import * as Haptics from 'expo-haptics'; -import { Colors, Font, Radius, Spacing, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../constants/theme'; -import { usePlayer } from '../store'; +import Svg, { Path, Rect } from 'react-native-svg'; +import { Colors, Font, Radius, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme'; +import { usePlayer, useLikes } from '../store'; import { useAudio } from '../hooks/useAudio'; import { coverUrl } from '../services/api'; export default function MiniPlayer() { - const { queue, index, playing, expanded, setExpanded } = usePlayer(); + const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer(); const { togglePlay } = useAudio(); - const track = queue[index]; - const insets = useSafeAreaInsets(); + const { toggleLike, isLiked } = useLikes(); + const track = queue[index]; const translateY = useSharedValue(0); const opacity = useSharedValue(1); @@ -27,13 +27,11 @@ export default function MiniPlayer() { onActive: (e) => { if (e.translationY < 0) { translateY.value = Math.max(e.translationY, -80); - opacity.value = 1 + e.translationY / 80; + opacity.value = 1 + e.translationY / 80; } }, onEnd: (e) => { - if (e.translationY < -40 || e.velocityY < -400) { - runOnJS(openPlayer)(); - } + if (e.translationY < -40 || e.velocityY < -400) runOnJS(openPlayer)(); translateY.value = withSpring(0); opacity.value = withSpring(1); }, @@ -54,88 +52,123 @@ export default function MiniPlayer() { togglePlay(); }, [togglePlay]); + const onNext = useCallback(() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const ni = usePlayer.getState().nextTrack(); + usePlayer.getState().setIndex(ni); + }, []); + + const onPrev = useCallback(() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const pi = usePlayer.getState().prevTrack(); + usePlayer.getState().setIndex(pi); + }, []); + if (!track || expanded) return null; - const cover = coverUrl(track.cover_url); + const cover = coverUrl(track.cover_url); + const liked = isLiked(track.id); + const pct = duration > 0 ? (progress / duration) * 100 : 0; return ( - - + {/* Glass background */} + + - + {/* Progress line at top */} + + + + + {/* Content row */} + {/* Cover */} {/* Info */} - - {track.title} - {track.artist} + + {track.title} + {track.artist} - {/* Play/Pause */} - - {playing ? : } - + {/* Controls */} + + {/* Like */} + { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} + hitSlop={10} + style={s.ctlBtn} + > + + + + - {/* Next */} - { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - const next = usePlayer.getState().nextTrack(); - usePlayer.getState().setIndex(next); - }} - hitSlop={12} - > - - + {/* Prev */} + { e.stopPropagation(); onPrev(); }} + hitSlop={10} + style={s.ctlBtn} + > + + + + + + {/* Play/Pause */} + { e.stopPropagation(); onPlayPress(); }} + hitSlop={10} + style={s.ctlBtn} + > + {playing + ? + : + } + + + {/* Next */} + { e.stopPropagation(); onNext(); }} + hitSlop={10} + style={s.ctlBtn} + > + + + + + - - {/* Progress line */} - ); } -function ProgressLine() { - const { progress, duration } = usePlayer(); - const pct = duration > 0 ? (progress / duration) * 100 : 0; - return ( - - - - ); -} - -function PlayIcon() { return ; } -function PauseIcon() { return ; } -function NextIcon() { return ; } - -const styles = StyleSheet.create({ - wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden' }, - border: { position: 'absolute', top: 0, left: 0, right: 0, height: StyleSheet.hairlineWidth, backgroundColor: Colors.border }, - row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.md, gap: Spacing.md }, - cover: { width: 42, height: 42, borderRadius: Radius.sm, backgroundColor: Colors.elevated }, - info: { flex: 1 }, - title: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' }, +const s = StyleSheet.create({ + wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderRadius: '14px 14px 0 0' as any, borderTopLeftRadius: 14, borderTopRightRadius: 14 }, + bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' }, + progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 }, + progressFill:{ height: 2, backgroundColor: Colors.accent }, + row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 }, + cover: { width: 44, height: 44, borderRadius: 9, backgroundColor: Colors.elevated, flexShrink: 0 }, + info: { flex: 1, minWidth: 0 }, + title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 }, - btn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' }, - progressBg: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, backgroundColor: Colors.border }, - progressFill:{ height: 2, backgroundColor: Colors.green }, + controls: { flexDirection: 'row', alignItems: 'center' }, + ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, }); diff --git a/components/Player.tsx b/components/Player.tsx index e0a3b8c..ab0fbf6 100644 --- a/components/Player.tsx +++ b/components/Player.tsx @@ -1,65 +1,100 @@ -import { useCallback, useEffect, type ReactNode } from 'react'; -import { View, Text, StyleSheet, Pressable, Dimensions } from 'react-native'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + View, Text, StyleSheet, Pressable, Dimensions, ScrollView, +} from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, - interpolate, useAnimatedGestureHandler, runOnJS, FadeIn, + useAnimatedGestureHandler, runOnJS, FadeIn, interpolate, } from 'react-native-reanimated'; import { PanGestureHandler } from 'react-native-gesture-handler'; import { Image } from 'expo-image'; import Slider from '@react-native-community/slider'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { BlurView } from 'expo-blur'; import * as Haptics from 'expo-haptics'; -import Svg, { Path, Polyline } from 'react-native-svg'; +import Svg, { Path, Polyline, Line, Rect } from 'react-native-svg'; import { Colors, Font, Radius, Spacing } from '../constants/theme'; -import { usePlayer } from '../store'; +import { usePlayer, useLikes } from '../store'; import { useAudio } from '../hooks/useAudio'; -import { coverUrl } from '../services/api'; +import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api'; const { width } = Dimensions.get('window'); -const COVER_SIZE = width - 64; +const COVER = width - 48; export default function Player() { const { queue, index, playing, progress, duration, repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle, } = usePlayer(); - const { togglePlay, seek } = useAudio(); + const { togglePlay, seek } = useAudio(); + const { toggleLike, isLiked } = useLikes(); const insets = useSafeAreaInsets(); const track = queue[index]; - const translateY = useSharedValue(0); - const coverScale = useSharedValue(1); + const [lyricsOpen, setLyricsOpen] = useState(false); + const [lrcLines, setLrcLines] = useState([]); + const [plainLyrics, setPlain] = useState(null); + const [lyricsLoading, setLyricsLoading] = useState(false); + const lyricsScrollRef = useRef(null); + const lineHeights = useRef([]); + + const translateY = useSharedValue(0); + const coverScale = useSharedValue(1); + const lyrSheetY = useSharedValue(500); useEffect(() => { coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 }); }, [playing]); + useEffect(() => { + setLrcLines([]); + setPlain(null); + setLyricsOpen(false); + }, [track?.id]); + + const loadLyrics = useCallback(async () => { + if (!track) return; + if (lrcLines.length > 0 || plainLyrics) { setLyricsOpen(true); return; } + setLyricsLoading(true); + const data = await fetchLyrics(track.id); + setLyricsLoading(false); + if (data?.synced) setLrcLines(parseLrc(data.synced)); + else if (data?.plain) setPlain(data.plain); + setLyricsOpen(true); + }, [track, lrcLines.length, plainLyrics]); + + // Auto-scroll lyrics to current line + const currentLineIdx = lrcLines.length > 0 + ? lrcLines.reduce((acc, line, i) => line.time <= progress ? i : acc, 0) + : -1; + + useEffect(() => { + if (!lyricsOpen || currentLineIdx < 0) return; + const y = lineHeights.current.slice(0, currentLineIdx).reduce((s, h) => s + h, 0); + lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 120), animated: true }); + }, [currentLineIdx, lyricsOpen]); + const gestureHandler = useAnimatedGestureHandler({ onStart: (_, ctx) => { ctx.startY = translateY.value; }, onActive: (e, ctx) => { if (e.translationY > 0) translateY.value = ctx.startY + e.translationY; }, onEnd: (e) => { - if (e.translationY > 120 || e.velocityY > 800) { - runOnJS(close)(); - } else { - translateY.value = withSpring(0, { damping: 20 }); - } + if (e.translationY > 100 || e.velocityY > 700) runOnJS(close)(); + else translateY.value = withSpring(0, { damping: 20 }); }, }); const containerStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); - + const overlayOpacity = useAnimatedStyle(() => ({ + opacity: interpolate(translateY.value, [0, 300], [1, 0]), + })); const coverStyle = useAnimatedStyle(() => ({ transform: [{ scale: coverScale.value }], })); - const overlayStyle = useAnimatedStyle(() => ({ - opacity: interpolate(translateY.value, [0, 300], [1, 0]), - })); - const close = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setExpanded(false); @@ -85,194 +120,221 @@ export default function Player() { if (!track) return null; - const cover = coverUrl(track.cover_url); - const pct = duration > 0 ? progress / duration : 0; + const cover = coverUrl(track.cover_url); + const pct = duration > 0 ? progress / duration : 0; + const liked = isLiked(track.id); return ( - - {/* Blurred background art */} + + {/* Blurred album art background */} - + + - - {/* Handle */} - - + + + {/* Drag handle + top label */} + + + Сейчас играет + + + + + - {/* Header */} - - Сейчас играет - - {/* Cover */} - - + + - {/* Info + like */} - - - {track.title} - {track.artist} + {/* Meta row */} + + + {track.title} + {track.artist} - + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} + hitSlop={12} + > + + + + - {/* Progress bar */} - + {/* Seek bar */} + seek(v * duration)} /> - - {fmt(progress)} - {fmt(duration)} + + {fmt(progress)} + {fmt(duration)} {/* Controls */} - + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}> - - + + + + - - - {playing ? : } + + {playing + ? + : + } - - + + + + - { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}> + + {/* Bottom row: lyrics + speed */} + + + + + + + {lyricsLoading ? 'Загрузка…' : 'Текст'} + + + + + + {/* Lyrics bottom sheet */} + {lyricsOpen && ( + + setLyricsOpen(false)} /> + + {/* Handle */} + setLyricsOpen(false)} style={s.lyrHandleBar}> + + + {/* Mini header */} + + {cover + ? + : + } + + {track.title} + {track.artist} + + + {/* Lyrics content */} + + {lrcLines.length > 0 + ? lrcLines.map((line, i) => ( + { lineHeights.current[i] = e.nativeEvent.layout.height; }} + > + {line.text} + + )) + : plainLyrics + ? {plainLyrics} + : Текст не найден + } + + + + )} ); } -function CtlBtn({ onPress, active = false, children }: { - onPress: () => void; active?: boolean; children: React.ReactNode; -}) { - const scale = useSharedValue(1); - const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); +function CtlBtn({ onPress, active = false, children }: { onPress: () => void; active?: boolean; children: React.ReactNode }) { + const sc = useSharedValue(1); + const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] })); return ( - + { scale.value = withSpring(0.82, { damping: 12 }); }} - onPressOut={() => { scale.value = withSpring(1, { damping: 12 }); }} + onPressIn={() => { sc.value = withSpring(0.82, { damping: 12 }); }} + onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} hitSlop={12} - > - {children} - + style={[s.ctlBtn, active && s.ctlBtnActive]} + >{children} ); } -function LikeButton() { - const scale = useSharedValue(1); - const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); - return ( - - { - scale.value = withSpring(1.35, { damping: 7 }, () => { - scale.value = withSpring(1, { damping: 12 }); - }); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - > - - - - - - ); -} - -function PlayIcon() { - return ; -} -function PauseIcon() { - return ; -} -function PrevIcon() { - return ( - - - - - ); -} -function NextIcon() { - return ( - - - - - ); -} function ShuffleIcon({ active }: { active: boolean }) { - const c = active ? Colors.green : Colors.text2; + const c = active ? Colors.accent : 'rgba(255,255,255,0.5)'; return ( - - - - - + + + + + ); } + function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) { - const c = repeat > 0 ? Colors.green : Colors.text2; + const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.5)'; return ( - - - - - + + + + + {repeat === 2 && ( - - 1 - + 1 )} ); @@ -283,27 +345,46 @@ function fmt(s: number) { return `${m}:${sec < 10 ? '0' : ''}${sec}`; } -const styles = StyleSheet.create({ - root: { zIndex: 999 }, - dim: { backgroundColor: 'rgba(0,0,0,0.6)' }, - sheet: { flex: 1, paddingHorizontal: Spacing.xl }, - handleWrap: { alignItems: 'center', paddingBottom: 4 }, - handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)' }, - header: { alignItems: 'center', marginBottom: Spacing.lg }, - headerTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600', letterSpacing: 0.5 }, - coverWrap: { alignItems: 'center', marginBottom: Spacing.xl }, - coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.6, shadowRadius: 32 }, - cover: { width: COVER_SIZE, height: COVER_SIZE, borderRadius: Radius.xl, backgroundColor: Colors.elevated }, - infoRow: { flexDirection: 'row', alignItems: 'center', marginBottom: Spacing.md, gap: Spacing.md }, - trackTitle: { color: Colors.text, fontSize: Font.xl, fontWeight: '700', letterSpacing: -0.3 }, - artist: { color: 'rgba(255,255,255,0.5)', fontSize: Font.md, marginTop: 4 }, - progressWrap: { marginBottom: Spacing.lg }, - timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -6 }, - timeText: { color: 'rgba(255,255,255,0.35)', fontSize: Font.xs }, - controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4 }, - ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full }, - ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' }, - playBtn: { width: 72, height: 72, borderRadius: 36, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center', shadowColor: '#fff', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 12 }, - repeatBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.green, alignItems: 'center', justifyContent: 'center' }, - repeatBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' }, +const s = StyleSheet.create({ + root: { zIndex: 999 }, + bgDim: { backgroundColor: 'rgba(0,0,0,0.65)' }, + sheet: { flex: 1, paddingHorizontal: 24 }, + topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 8 }, + handle: { display: 'none' }, + topLabel: { color: 'rgba(255,255,255,0.38)', fontSize: 11, fontWeight: '600', letterSpacing: 1.2, textTransform: 'uppercase' }, + closeBtn: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.1)', alignItems: 'center', justifyContent: 'center' }, + coverWrap: { alignItems: 'center', marginVertical: 20 }, + coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.7, shadowRadius: 32 }, + cover: { width: COVER, height: COVER, borderRadius: Radius.xl, backgroundColor: Colors.elevated }, + metaRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 12 }, + metaText: { flex: 1 }, + trackTitle: { color: Colors.text, fontSize: 22, fontWeight: '700', letterSpacing: -0.3 }, + artist: { color: 'rgba(255,255,255,0.55)', fontSize: 17, marginTop: 4 }, + seekWrap: { marginBottom: 8 }, + timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 }, + timeText: { color: 'rgba(255,255,255,0.35)', fontSize: Font.xs }, + controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 20 }, + ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full }, + ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' }, + playBtn: { width: 60, height: 60, borderRadius: 30, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center' }, + bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20 }, + lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 14, paddingVertical: 8, borderRadius: Radius.full }, + lyrBtnText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '600' }, + repBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center' }, + repBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' }, + // Lyrics sheet + lyrSheet: { position: 'absolute', inset: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' }, + lyrBg: { ...StyleSheet.absoluteFillObject }, + lyrInner: { height: '70%', borderRadius: 20, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.1)' }, + lyrHandleBar: { alignItems: 'center', paddingTop: 12, paddingBottom: 4 }, + lyrHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.25)' }, + lyrHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 1, borderColor: 'rgba(255,255,255,0.06)' }, + lyrThumb: { width: 40, height: 40, borderRadius: 8, flexShrink: 0 }, + lyrTrackTitle: { fontSize: 13, fontWeight: '600', color: '#fff' }, + lyrArtist: { fontSize: 11, color: 'rgba(255,255,255,0.55)' }, + lyrScroll: { flex: 1 }, + lyrLine: { fontSize: 20, lineHeight: 32, color: 'rgba(255,255,255,0.28)', fontWeight: '600', marginBottom: 8, textAlign: 'center' }, + lyrLineActive: { fontSize: 28, lineHeight: 42, color: '#fff', marginBottom: 10 }, + plainLyrics: { fontSize: 15, lineHeight: 26, color: 'rgba(255,255,255,0.75)', textAlign: 'center' }, + lyrEmpty: { color: 'rgba(255,255,255,0.35)', fontSize: 14, textAlign: 'center', marginTop: 40 }, }); diff --git a/constants/theme.ts b/constants/theme.ts index 6f3bb39..683f198 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -1,15 +1,20 @@ export const Colors = { bg: '#0a0a0a', - surface: '#141414', - elevated: '#1e1e1e', - border: '#2a2a2a', - green: '#1db954', - greenDim: '#168f3f', + surface: '#111111', + elevated: '#1a1a1a', + subtle: '#2a2a2a', + border: 'rgba(255,255,255,0.07)', + border2: 'rgba(255,255,255,0.14)', + accent: '#4F86F5', + accentDim:'rgba(79,134,245,0.18)', + pink: '#e84393', text: '#f0f0f0', text2: '#888888', text3: '#444444', red: '#e84040', white: '#ffffff', + // legacy alias + green: '#4F86F5', }; export const Spacing = { @@ -23,21 +28,21 @@ export const Spacing = { export const Radius = { sm: 8, - md: 12, - lg: 16, - xl: 20, + md: 10, + lg: 14, + xl: 16, full: 9999, }; export const Font = { xs: 11, sm: 13, - md: 15, + md: 14, lg: 17, xl: 20, xxl: 26, xxxl: 32, }; -export const TAB_BAR_HEIGHT = 88; +export const TAB_BAR_HEIGHT = 54; export const MINI_PLAYER_HEIGHT = 64; diff --git a/package.json b/package.json index 549a572..f95a331 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "react-native-reanimated": "~3.10.1", "react-native-safe-area-context": "4.10.5", "react-native-screens": "3.31.1", - "react-native-svg": "^15.0.0", "zustand": "^4.5.4", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/slider": "4.5.2" diff --git a/services/api.ts b/services/api.ts index 98808a3..0940533 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,27 +1,25 @@ import * as SecureStore from 'expo-secure-store'; -const BASE = 'https://music.nodeshift.space'; -const CLOUD = 'https://cloud.nodeshift.space'; +const BASE = 'https://music.nodeshift.space'; +const CLOUD = 'https://cloud.nodeshift.space'; const CLIENT_ID = 'nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U'; -const REDIRECT = 'burmalda://auth/callback'; +const REDIRECT = 'burmalda://auth/callback'; -// ── Token storage ────────────────────────────────────────────────────────────── +// ── Token storage ───────────────────────────────────────────────────────────── export async function saveTokens(access: string, refresh: string) { await SecureStore.setItemAsync('access_token', access); await SecureStore.setItemAsync('refresh_token', refresh); } - export async function getAccessToken(): Promise { return SecureStore.getItemAsync('access_token'); } - export async function clearTokens() { await SecureStore.deleteItemAsync('access_token'); await SecureStore.deleteItemAsync('refresh_token'); } -// ── Auth fetch helper ────────────────────────────────────────────────────────── +// ── Fetch helper ────────────────────────────────────────────────────────────── async function apiFetch(path: string, opts: RequestInit = {}): Promise { const token = await getAccessToken(); @@ -33,15 +31,10 @@ async function apiFetch(path: string, opts: RequestInit = {}): Promise return fetch(`${BASE}${path}`, { ...opts, headers }); } -// ── OAuth ────────────────────────────────────────────────────────────────────── +// ── OAuth ───────────────────────────────────────────────────────────────────── export function buildOAuthUrl(state: string): string { - const p = new URLSearchParams({ - client_id: CLIENT_ID, - redirect_uri: REDIRECT, - state, - scope: 'profile email', - }); + const p = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT, state, scope: 'profile email' }); return `${CLOUD}/api/cloud/oauth/authorize?${p}`; } @@ -58,7 +51,7 @@ export async function exchangeCode(code: string): Promise<{ access_token: string return res.json(); } -// ── Types ────────────────────────────────────────────────────────────────────── +// ── Types ───────────────────────────────────────────────────────────────────── export interface Track { id: number; @@ -88,7 +81,12 @@ export interface User { plan: string; } -// ── API calls ────────────────────────────────────────────────────────────────── +export interface Lyrics { + synced: string | null; + plain: string | null; +} + +// ── API calls ───────────────────────────────────────────────────────────────── export async function getTracks(): Promise { const r = await apiFetch('/api/music/tracks'); @@ -114,6 +112,12 @@ export async function searchTracks(q: string): Promise { return r.json(); } +export async function fetchLyrics(id: number): Promise { + const r = await apiFetch(`/api/music/lyrics/${id}`); + if (!r.ok) return null; + return r.json(); +} + export function streamUrl(filename: string): string { return `${BASE}/api/music/stream/${filename}`; } @@ -123,3 +127,21 @@ export function coverUrl(filename: string | null): string | null { if (filename.startsWith('http')) return filename; return `${BASE}/api/music/cover/${filename}`; } + +// ── LRC parser ──────────────────────────────────────────────────────────────── + +export interface LrcLine { + time: number; + text: string; +} + +export function parseLrc(lrc: string): LrcLine[] { + const lines: LrcLine[] = []; + for (const raw of lrc.split('\n')) { + const m = raw.match(/^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$/); + if (!m) continue; + const time = parseInt(m[1]) * 60 + parseFloat(`${m[2]}.${m[3]}`); + if (m[4].trim()) lines.push({ time, text: m[4].trim() }); + } + return lines.sort((a, b) => a.time - b.time); +} diff --git a/store/index.ts b/store/index.ts index 186723c..6d2951f 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,26 +1,29 @@ import { create } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { Track, User } from '../services/api'; +// ── Player ──────────────────────────────────────────────────────────────────── + interface PlayerState { queue: Track[]; index: number; playing: boolean; progress: number; duration: number; - repeat: 0 | 1 | 2; // 0=off 1=all 2=one + repeat: 0 | 1 | 2; shuffle: boolean; expanded: boolean; - setQueue: (tracks: Track[], index?: number) => void; - setIndex: (i: number) => void; - setPlaying: (v: boolean) => void; - setProgress:(v: number) => void; - setDuration:(v: number) => void; - cycleRepeat:() => void; + setQueue: (tracks: Track[], index?: number) => void; + setIndex: (i: number) => void; + setPlaying: (v: boolean) => void; + setProgress: (v: number) => void; + setDuration: (v: number) => void; + cycleRepeat: () => void; toggleShuffle: () => void; - setExpanded:(v: boolean) => void; - nextTrack: () => number; - prevTrack: () => number; + setExpanded: (v: boolean) => void; + nextTrack: () => number; + prevTrack: () => number; } export const usePlayer = create((set, get) => ({ @@ -33,14 +36,14 @@ export const usePlayer = create((set, get) => ({ shuffle: false, expanded: false, - setQueue: (tracks, index = 0) => set({ queue: tracks, index }), - setIndex: (index) => set({ index }), - setPlaying: (playing) => set({ playing }), - setProgress: (progress) => set({ progress }), - setDuration: (duration) => set({ duration }), - cycleRepeat: () => set((s) => ({ repeat: ((s.repeat + 1) % 3) as 0 | 1 | 2 })), + setQueue: (tracks, index = 0) => set({ queue: tracks, index }), + setIndex: (index) => set({ index }), + setPlaying: (playing) => set({ playing }), + setProgress: (progress) => set({ progress }), + setDuration: (duration) => set({ duration }), + cycleRepeat: () => set((s) => ({ repeat: ((s.repeat + 1) % 3) as 0 | 1 | 2 })), toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })), - setExpanded: (expanded) => set({ expanded }), + setExpanded: (expanded) => set({ expanded }), nextTrack: () => { const { queue, index, shuffle, repeat } = get(); @@ -50,7 +53,6 @@ export const usePlayer = create((set, get) => ({ if (next >= queue.length) return repeat === 1 ? 0 : index; return next; }, - prevTrack: () => { const { queue, index, shuffle } = get(); if (shuffle) return Math.floor(Math.random() * queue.length); @@ -58,16 +60,50 @@ export const usePlayer = create((set, get) => ({ }, })); +// ── Auth ────────────────────────────────────────────────────────────────────── + interface AuthState { - user: User | null; - ready: boolean; - setUser: (u: User | null) => void; - setReady:(v: boolean) => void; + user: User | null; + ready: boolean; + setUser: (u: User | null) => void; + setReady: (v: boolean) => void; } export const useAuth = create((set) => ({ - user: null, - ready: false, - setUser: (user) => set({ user }), - setReady:(ready) => set({ ready }), + user: null, + ready: false, + setUser: (user) => set({ user }), + setReady: (ready) => set({ ready }), +})); + +// ── Likes ───────────────────────────────────────────────────────────────────── + +const LIKES_KEY = 'ns-music-likes'; + +interface LikesState { + likes: Set; + loadLikes: () => Promise; + toggleLike: (id: number) => void; + isLiked: (id: number) => boolean; +} + +export const useLikes = create((set, get) => ({ + likes: new Set(), + + loadLikes: async () => { + try { + const raw = await AsyncStorage.getItem(LIKES_KEY); + const arr: number[] = raw ? JSON.parse(raw) : []; + set({ likes: new Set(arr) }); + } catch {} + }, + + toggleLike: (id) => { + const next = new Set(get().likes); + if (next.has(id)) next.delete(id); else next.add(id); + set({ likes: next }); + AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {}); + }, + + isLiked: (id) => get().likes.has(id), }));