diff --git a/app.json b/app.json index 2d27328..9dc9658 100644 --- a/app.json +++ b/app.json @@ -29,6 +29,7 @@ "scheme": "burmalda", "plugins": [ "expo-router", + "react-native-track-player", [ "expo-updates", { diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 2125e44..65f5986 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -5,6 +5,7 @@ import { BlurView } from 'expo-blur'; import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg'; import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme'; import { useAuth, usePlayer, useLikes } from '../../store'; +import { useAudio } from '../../hooks/useAudio'; import MiniPlayer from '../../components/MiniPlayer'; import Player from '../../components/Player'; @@ -12,6 +13,7 @@ export default function TabsLayout() { const { user, ready } = useAuth(); const { expanded } = usePlayer(); const { loadLikes } = useLikes(); + useAudio(); // single audio controller for the whole app useEffect(() => { if (ready && !user) router.replace('/auth'); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 3bc359a..2b3305e 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -2,12 +2,12 @@ import { useEffect, useState, useCallback } from 'react'; import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl } from 'react-native'; import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { 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, useLikes } from '../../store'; +import CoverImage from '../../components/CoverImage'; const { width } = Dimensions.get('window'); const CARD_W = (width - Spacing.md * 3) / 2; @@ -47,27 +47,30 @@ export default function HomeScreen() { refreshControl={} showsVerticalScrollIndicator={false} > - {/* Header */} NodeShift Music - {/* Now playing strip (if track exists) */} - {/* Albums */} {albums.length > 0 && ( Альбомы {albums.map((a, i) => ( - + { + const albumTracks = tracks.filter(t => t.album === a.title); + if (albumTracks.length > 0) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setQueue(albumTracks, 0); + usePlayer.getState().setExpanded(true); + } + }} /> ))} )} - {/* Tracks */} {tracks.length > 0 && ( Треки @@ -92,15 +95,21 @@ function NowPlayingStrip() { const track = queue[index]; if (!track) return null; const cover = coverUrl(track.cover_url); + return ( - {cover && } + {/* Blurred cover background */} + setExpanded(true)}> - {cover - ? - : - } + Сейчас играет {track.title} @@ -108,7 +117,7 @@ function NowPlayingStrip() { {playing - ? {[1,2,3].map(i => )} + ? {[1, 2, 3].map(i => )} : } @@ -117,25 +126,23 @@ function NowPlayingStrip() { ); } -function AlbumCard({ album, delay }: { album: Album; delay: number }) { +function AlbumCard({ album, delay, onPress }: { album: Album; delay: number; onPress: () => void }) { const scale = useSharedValue(1); const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); const cover = coverUrl(album.cover_url); + return ( { scale.value = withSpring(0.95, { damping: 15 }); }} onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} + onPress={onPress} style={styles.albumCard} > - + {album.title} {album.artist} + {album.track_count} треков ); @@ -151,12 +158,7 @@ function TrackRow({ track, index, liked, onPress, onLike }: { return ( {index + 1} - + {track.title} {track.artist} @@ -177,35 +179,33 @@ function fmt(s: number) { } const styles = StyleSheet.create({ - scroll: { flex: 1, backgroundColor: Colors.bg }, - content: { paddingHorizontal: Spacing.md }, - pageHeader: { marginBottom: Spacing.md }, - pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 }, - section: { marginBottom: Spacing.xl }, - secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md }, - hRow: { gap: Spacing.md, paddingRight: Spacing.md }, - albumCard: { width: CARD_W }, - 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 }, + scroll: { flex: 1, backgroundColor: Colors.bg }, + content: { paddingHorizontal: Spacing.md }, + pageHeader: { marginBottom: Spacing.md }, + pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 }, + section: { marginBottom: Spacing.xl }, + secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md }, + hRow: { gap: Spacing.md, paddingRight: Spacing.md }, + albumCard: { width: CARD_W }, + albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' }, + albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 }, + albumCount: { color: Colors.text3, fontSize: Font.xs, marginTop: 2 }, + trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: Spacing.sm }, + trackRowActive: { backgroundColor: Colors.surface }, + trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, + trackInfo: { flex: 1 }, + trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, + trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, + trackDur: { color: Colors.text3, fontSize: Font.xs }, // Now playing strip - npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 }, - npDim: { backgroundColor: 'rgba(0,0,0,0.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 }, + npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 }, + npDim: { backgroundColor: 'rgba(0,0,0,0.5)' }, + npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 }, + npInfo: { flex: 1 }, + npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 }, + npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, + npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 }, + npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 }, + npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 }, }); diff --git a/app/auth.tsx b/app/auth.tsx index 555fa19..4b4a945 100644 --- a/app/auth.tsx +++ b/app/auth.tsx @@ -14,6 +14,7 @@ import Svg, { Path, Circle, Polyline, Line } from 'react-native-svg'; import { Colors, Font, Radius, Spacing } from '../constants/theme'; import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api'; import { useAuth } from '../store'; +import { saveUser } from '../hooks/useAuth'; WebBrowser.maybeCompleteAuthSession(); @@ -49,6 +50,7 @@ export default function AuthScreen() { } const data = await exchangeCode(code); await saveTokens(data.access_token, data.refresh_token); + await saveUser(data.user); setUser(data.user); router.replace('/(tabs)'); } catch (e: any) { @@ -60,13 +62,13 @@ export default function AuthScreen() { return ( - - - + + + - + @@ -86,14 +88,14 @@ export default function AuthScreen() { onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }} onPress={handleLogin} > - + {loading - ? + ? : ( - - - + + + )} @@ -149,15 +151,15 @@ function Blob({ color, size, left, top, delay }: { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }, - blob: { position: 'absolute', opacity: 0.15 }, + blob: { position: 'absolute', opacity: 0.12 }, card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 }, logoWrap: { alignItems: 'center', marginBottom: 40 }, - logoIcon: { width: 84, height: 84, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.md, shadowColor: '#1db954', shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 }, + logoIcon: { width: 84, height: 84, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.md, shadowColor: Colors.accent, shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 }, logoTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: 4 }, logoSub: { color: Colors.text3, fontSize: Font.sm, fontWeight: '500' }, - btnWrap: { width: '100%', borderRadius: Radius.md, overflow: 'hidden', shadowColor: '#1db954', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 }, + btnWrap: { width: '100%', borderRadius: Radius.md, overflow: 'hidden', shadowColor: Colors.accent, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 }, btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 }, - btnText: { color: '#000', fontSize: Font.md, fontWeight: '700' }, + btnText: { color: '#fff', fontSize: Font.md, fontWeight: '700' }, error: { color: Colors.red, fontSize: Font.sm, textAlign: 'center', marginTop: Spacing.sm }, footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 }, }); diff --git a/components/CoverImage.tsx b/components/CoverImage.tsx new file mode 100644 index 0000000..e01465f --- /dev/null +++ b/components/CoverImage.tsx @@ -0,0 +1,73 @@ +import { useState, useEffect } from 'react'; +import { Text, ViewStyle } from 'react-native'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Radius } from '../constants/theme'; + +// 12 distinct dark gradients — one is assigned per title hash +const GRADIENTS: [string, string][] = [ + ['#12234a', '#1e4080'], + ['#1a0e3a', '#3d1a7a'], + ['#0e2a3a', '#1a5a7a'], + ['#2a0e1e', '#6a1a3e'], + ['#0e2a1e', '#1a6a3e'], + ['#2a1e0e', '#7a4a1e'], + ['#1e0e2a', '#4a1e6a'], + ['#0e1e2a', '#1e4a6a'], + ['#2a0e2a', '#6a1a6a'], + ['#1e2a0e', '#4a6a1e'], + ['#2a1e1e', '#6a3a3a'], + ['#0e2a2a', '#1a6a6a'], +]; + +function textHash(s: string): number { + let h = 0; + for (const c of s) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0; + return Math.abs(h); +} + +interface Props { + uri: string | null; + title: string; + size: number; + radius?: number; + style?: ViewStyle; + blurRadius?: number; +} + +export default function CoverImage({ uri, title, size, radius = Radius.sm, style, blurRadius }: Props) { + const [failed, setFailed] = useState(false); + + // Reset error state when URI changes (new track loaded) + useEffect(() => { setFailed(false); }, [uri]); + + if (uri && !failed) { + return ( + setFailed(true)} + /> + ); + } + + const [c1, c2] = GRADIENTS[textHash(title || '') % GRADIENTS.length]; + const letter = (title || '?').trim().toUpperCase()[0]; + const fontSize = size * 0.36; + + return ( + + + {letter} + + + ); +} diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index c5e1108..95a5813 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -8,15 +8,14 @@ import { PanGestureHandler } from 'react-native-gesture-handler'; import { BlurView } from 'expo-blur'; import { Image } from 'expo-image'; import * as Haptics from 'expo-haptics'; -import Svg, { Path, Rect } from 'react-native-svg'; -import { Colors, Font, Radius, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme'; +import Svg, { Path } from 'react-native-svg'; +import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme'; import { usePlayer, useLikes } from '../store'; -import { useAudio } from '../hooks/useAudio'; +import { togglePlay, seekTo } from '../hooks/useAudio'; import { coverUrl } from '../services/api'; export default function MiniPlayer() { const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer(); - const { togglePlay } = useAudio(); const { toggleLike, isLiked } = useLikes(); const track = queue[index]; @@ -47,11 +46,6 @@ export default function MiniPlayer() { setExpanded(true); }, [setExpanded]); - const onPlayPress = useCallback(() => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - togglePlay(); - }, [togglePlay]); - const onNext = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); const ni = usePlayer.getState().nextTrack(); @@ -66,9 +60,9 @@ export default function MiniPlayer() { if (!track || expanded) return null; - const cover = coverUrl(track.cover_url); - const liked = isLiked(track.id); - const pct = duration > 0 ? (progress / duration) * 100 : 0; + const cover = coverUrl(track.cover_url); + const liked = isLiked(track.id); + const pct = duration > 0 ? (progress / duration) * 100 : 0; return ( @@ -77,80 +71,41 @@ export default function MiniPlayer() { entering={FadeInDown.springify()} exiting={FadeOutDown.springify()} > - {/* Glass background */} - {/* Progress line at top */} + {/* Progress line */} - {/* Content row */} - {/* Cover */} - - {/* Info */} {track.title} {track.artist} - - {/* Controls */} - {/* Like */} - { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} - hitSlop={10} - style={s.ctlBtn} - > + { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}> - + - - {/* Prev */} - { e.stopPropagation(); onPrev(); }} - hitSlop={10} - style={s.ctlBtn} - > - - - + { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}> + - - {/* Play/Pause */} - { e.stopPropagation(); onPlayPress(); }} - hitSlop={10} - style={s.ctlBtn} - > + { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}> {playing ? - : - } + : } - - {/* Next */} - { e.stopPropagation(); onNext(); }} - hitSlop={10} - style={s.ctlBtn} - > - - - + { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}> + @@ -160,7 +115,7 @@ export default function MiniPlayer() { } 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 }, + wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', 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 }, diff --git a/components/Player.tsx b/components/Player.tsx index ab0fbf6..36cc170 100644 --- a/components/Player.tsx +++ b/components/Player.tsx @@ -1,7 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { - View, Text, StyleSheet, Pressable, Dimensions, ScrollView, -} from 'react-native'; +import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, useAnimatedGestureHandler, runOnJS, FadeIn, interpolate, @@ -13,9 +11,9 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BlurView } from 'expo-blur'; import * as Haptics from 'expo-haptics'; import Svg, { Path, Polyline, Line, Rect } from 'react-native-svg'; -import { Colors, Font, Radius, Spacing } from '../constants/theme'; +import { Colors, Font, Radius } from '../constants/theme'; import { usePlayer, useLikes } from '../store'; -import { useAudio } from '../hooks/useAudio'; +import { togglePlay, seekTo } from '../hooks/useAudio'; import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api'; const { width } = Dimensions.get('window'); @@ -26,30 +24,26 @@ export default function Player() { queue, index, playing, progress, duration, repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle, } = usePlayer(); - const { togglePlay, seek } = useAudio(); const { toggleLike, isLiked } = useLikes(); const insets = useSafeAreaInsets(); const track = queue[index]; - const [lyricsOpen, setLyricsOpen] = useState(false); - const [lrcLines, setLrcLines] = useState([]); - const [plainLyrics, setPlain] = useState(null); - const [lyricsLoading, setLyricsLoading] = useState(false); + 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 lineHeights = useRef([]); - const translateY = useSharedValue(0); - const coverScale = useSharedValue(1); - const lyrSheetY = useSharedValue(500); + const translateY = useSharedValue(0); + const coverScale = useSharedValue(1); useEffect(() => { coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 }); }, [playing]); useEffect(() => { - setLrcLines([]); - setPlain(null); - setLyricsOpen(false); + setLrcLines([]); setPlain(null); setLyricsOpen(false); }, [track?.id]); const loadLyrics = useCallback(async () => { @@ -63,37 +57,28 @@ export default function Player() { 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); + const y = lineHeights.current.slice(0, currentLineIdx).reduce((sum, h) => sum + 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; - }, + onActive: (e, ctx) => { if (e.translationY > 0) translateY.value = ctx.startY + e.translationY; }, onEnd: (e) => { 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 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 close = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); @@ -108,95 +93,60 @@ export default function Player() { const onPrev = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - if (progress > 3) { seek(0); return; } + if (progress > 3) { seekTo(0); return; } const pi = usePlayer.getState().prevTrack(); usePlayer.getState().setIndex(pi); - }, [progress, seek]); - - const onPlay = useCallback(() => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - togglePlay(); - }, [togglePlay]); + }, [progress]); if (!track) return null; - const cover = coverUrl(track.cover_url); - const pct = duration > 0 ? progress / duration : 0; - const liked = isLiked(track.id); + const cover = coverUrl(track.cover_url); + const pct = duration > 0 ? progress / duration : 0; + const liked = isLiked(track.id); return ( - {/* Blurred album art background */} - + {/* Blurred background */} + - + - {/* Drag handle + top label */} - - + {/* Top bar */} + Сейчас играет - + {/* Cover */} - + - {/* Meta row */} + {/* Meta */} {track.title} {track.artist} - { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} - hitSlop={12} - > + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={12}> - + - {/* Seek bar */} + {/* Seek */} - seek(v * duration)} - /> + seekTo(v * duration)} /> {fmt(progress)} {fmt(duration)} @@ -214,11 +164,10 @@ export default function Player() { - + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}> {playing - ? - : - } + ? + : } @@ -231,7 +180,7 @@ export default function Player() { - {/* Bottom row: lyrics + speed */} + {/* Lyrics button */} @@ -246,47 +195,33 @@ export default function Player() { - {/* Lyrics bottom sheet */} + {/* Lyrics sheet */} {lyricsOpen && ( - setLyricsOpen(false)} /> + 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} - - )) + { lineHeights.current[i] = e.nativeEvent.layout.height; }}> + {line.text} + + )) : plainLyrics ? {plainLyrics} - : Текст не найден - } + : Текст не найден} @@ -300,13 +235,9 @@ function CtlBtn({ onPress, active = false, children }: { onPress: () => void; ac const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] })); return ( - { sc.value = withSpring(0.82, { damping: 12 }); }} - onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} - hitSlop={12} - style={[s.ctlBtn, active && s.ctlBtnActive]} - >{children} + { sc.value = withSpring(0.82, { damping: 12 }); }} onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} hitSlop={12} style={[s.ctlBtn, active && s.ctlBtnActive]}> + {children} + ); } @@ -316,9 +247,9 @@ function ShuffleIcon({ active }: { active: boolean }) { return ( - + - + ); } @@ -333,16 +264,14 @@ function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) { - {repeat === 2 && ( - 1 - )} + {repeat === 2 && 1} ); } -function fmt(s: number) { - const m = Math.floor(s / 60), sec = Math.floor(s % 60); - return `${m}:${sec < 10 ? '0' : ''}${sec}`; +function fmt(sec: number) { + const m = Math.floor(sec / 60), s = Math.floor(sec % 60); + return `${m}:${s < 10 ? '0' : ''}${s}`; } const s = StyleSheet.create({ @@ -350,7 +279,6 @@ const s = StyleSheet.create({ 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 }, @@ -367,15 +295,13 @@ const s = StyleSheet.create({ 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 }, + bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }, 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)' }, + lyrSheet: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' }, + lyrInner: { height: '70%', borderTopLeftRadius: 20, borderTopRightRadius: 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)' }, diff --git a/constants/theme.ts b/constants/theme.ts index 683f198..7b3133c 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -44,5 +44,5 @@ export const Font = { xxxl: 32, }; -export const TAB_BAR_HEIGHT = 54; +export const TAB_BAR_HEIGHT = 88; export const MINI_PLAYER_HEIGHT = 64; diff --git a/hooks/PlaybackService.ts b/hooks/PlaybackService.ts new file mode 100644 index 0000000..c78c73b --- /dev/null +++ b/hooks/PlaybackService.ts @@ -0,0 +1,55 @@ +import TrackPlayer, { Event } from 'react-native-track-player'; +import { usePlayer } from '../store'; +import { loadAndPlay } from './useAudio'; + +export async function PlaybackService() { + TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play()); + TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause()); + + TrackPlayer.addEventListener(Event.RemoteNext, async () => { + const state = usePlayer.getState(); + const ni = state.nextTrack(); + state.setIndex(ni); + const track = state.queue[ni]; + if (track) await loadAndPlay(track).catch(console.error); + }); + + TrackPlayer.addEventListener(Event.RemotePrevious, async () => { + const state = usePlayer.getState(); + const progress = await TrackPlayer.getProgress(); + if (progress.position > 3) { + await TrackPlayer.seekTo(0); + return; + } + const pi = state.prevTrack(); + state.setIndex(pi); + const track = state.queue[pi]; + if (track) await loadAndPlay(track).catch(console.error); + }); + + TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => + TrackPlayer.seekTo(position), + ); + + TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => { + const state = usePlayer.getState(); + const prevIdx = state.index; + + if (state.repeat === 2) { + await TrackPlayer.seekTo(0); + await TrackPlayer.play(); + return; + } + + const ni = state.nextTrack(); + state.setIndex(ni); + + if (ni === prevIdx) { + state.setPlaying(false); + return; + } + + const track = state.queue[ni]; + if (track) await loadAndPlay(track).catch(console.error); + }); +} diff --git a/hooks/useAudio.ts b/hooks/useAudio.ts index 77f6d46..ebaa9c6 100644 --- a/hooks/useAudio.ts +++ b/hooks/useAudio.ts @@ -1,96 +1,106 @@ -import { useEffect, useRef, useCallback } from 'react'; -import { Audio, AVPlaybackStatus } from 'expo-av'; +import { useEffect } from 'react'; +import TrackPlayer, { + State, + Capability, + AppKilledPlaybackBehavior, + usePlaybackState, + useProgress, +} from 'react-native-track-player'; import { usePlayer } from '../store'; +import { coverUrl, Track } from '../services/api'; -let _sound: Audio.Sound | null = null; +let _setupDone = false; +let _lastLoadedId: number | null = null; -// Module-level singleton so the sound survives re-renders -export async function getSound() { return _sound; } - -export function useAudio() { - const initialized = useRef(false); - const { - queue, index, playing, - setPlaying, setProgress, setDuration, - nextTrack, setIndex, repeat, - } = usePlayer(); - - const currentTrack = queue[index] ?? null; - - // Init audio session once - useEffect(() => { - if (initialized.current) return; - initialized.current = true; - Audio.setAudioModeAsync({ - staysActiveInBackground: true, - playsInSilentModeIOS: true, - interruptionModeIOS: 1, - shouldDuckAndroid: true, +async function ensureSetup() { + if (_setupDone) return; + _setupDone = true; + try { + await TrackPlayer.setupPlayer({ minBuffer: 15, maxBuffer: 50, playBuffer: 2.5 }); + await TrackPlayer.updateOptions({ + android: { + appKilledPlaybackBehavior: + AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification, + }, + capabilities: [ + Capability.Play, + Capability.Pause, + Capability.SkipToNext, + Capability.SkipToPrevious, + Capability.SeekTo, + ], + compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext], }); + } catch (e: any) { + // Player already set up on hot reload — update options only + if (e?.message?.toLowerCase().includes('already')) { + _setupDone = true; + return; + } + _setupDone = false; + throw e; + } +} + +// Load a track and start playing. Mutex prevents double-load on the same ID. +export async function loadAndPlay(track: Track) { + if (_lastLoadedId === track.id) return; + _lastLoadedId = track.id; + try { + await TrackPlayer.reset(); + await TrackPlayer.add({ + id: String(track.id), + url: track.cdn_url2 || track.cdn_url, + title: track.title, + artist: track.artist, + artwork: coverUrl(track.cover_url) ?? undefined, + duration: track.duration, + }); + await TrackPlayer.play(); + } catch (e) { + _lastLoadedId = null; + throw e; + } +} + +export async function togglePlay() { + const { state } = await TrackPlayer.getPlaybackState(); + if (state === State.Playing) await TrackPlayer.pause(); + else await TrackPlayer.play(); +} + +export async function seekTo(seconds: number) { + await TrackPlayer.seekTo(seconds); +} + +// Call ONCE from the root layout only +export function useAudio() { + const { queue, index, setPlaying, setProgress, setDuration } = usePlayer(); + const currentTrack = queue[index] ?? null; + const playbackState = usePlaybackState(); + const progress = useProgress(500); + + // One-time player setup + useEffect(() => { + ensureSetup().catch(console.error); }, []); - // Load new track whenever index/id changes + // Sync TrackPlayer progress → store + useEffect(() => { + setProgress(progress.position); + setDuration(progress.duration); + }, [progress.position, progress.duration]); + + // Sync TrackPlayer playing state → store + useEffect(() => { + setPlaying(playbackState.state === State.Playing); + }, [playbackState.state]); + + // Load new track when queue index changes useEffect(() => { if (!currentTrack) return; - - const load = async () => { - // Unload previous - if (_sound) { - await _sound.stopAsync().catch(() => {}); - await _sound.unloadAsync().catch(() => {}); - _sound = null; - } - - const { sound } = await Audio.Sound.createAsync( - { uri: currentTrack.stream_url }, - { shouldPlay: true, progressUpdateIntervalMillis: 500 }, - (status: AVPlaybackStatus) => { - if (!status.isLoaded) return; - setProgress(status.positionMillis / 1000); - setDuration((status.durationMillis ?? 0) / 1000); - if (status.isPlaying !== usePlayer.getState().playing) { - setPlaying(status.isPlaying); - } - if (status.didJustFinish) { - const state = usePlayer.getState(); - const ni = state.nextTrack(); - if (ni !== state.index || state.repeat === 2) { - setIndex(ni); - } else { - setPlaying(false); - } - } - }, - ); - _sound = sound; - }; - - load().catch(console.error); + loadAndPlay(currentTrack).catch(console.error); }, [currentTrack?.id]); - const togglePlay = useCallback(async () => { - if (!_sound) return; - const status = await _sound.getStatusAsync(); - if (!status.isLoaded) return; - if (status.isPlaying) { - await _sound.pauseAsync(); - } else { - await _sound.playAsync(); - } - }, []); - - const seek = useCallback(async (seconds: number) => { - if (!_sound) return; - await _sound.setPositionAsync(seconds * 1000); - }, []); - - // Cleanup on unmount - useEffect(() => { - return () => { - _sound?.unloadAsync().catch(() => {}); - _sound = null; - }; - }, []); - - return { togglePlay, seek }; + return { togglePlay, seek: seekTo }; } diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index 5df3aaa..eea0c17 100644 --- a/hooks/useAuth.ts +++ b/hooks/useAuth.ts @@ -1,7 +1,21 @@ import { useEffect } from 'react'; -import { getAccessToken, getMe, clearTokens } from '../services/api'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { getAccessToken, getMe, clearTokens, User } from '../services/api'; import { useAuth } from '../store'; +const USER_KEY = 'ns-music-user'; + +export async function saveUser(user: User): Promise { + await AsyncStorage.setItem(USER_KEY, JSON.stringify(user)); +} + +export async function loadCachedUser(): Promise { + try { + const raw = await AsyncStorage.getItem(USER_KEY); + return raw ? JSON.parse(raw) : null; + } catch { return null; } +} + export function useBootAuth() { const { setUser, setReady } = useAuth(); @@ -10,10 +24,26 @@ export function useBootAuth() { try { const token = await getAccessToken(); if (!token) { setReady(true); return; } + + // Use cached user for immediate display while validating + const cached = await loadCachedUser(); + if (cached) setUser(cached); + + // Validate token and get fresh data const me = await getMe(); - if (me) setUser(me); + if (me) { + // Preserve plan from initial login since no endpoint exposes it + const merged: User = { ...me, plan: cached?.plan || me.plan }; + setUser(merged); + saveUser(merged).catch(() => {}); + } else { + // Token invalid — clear everything + await clearTokens(); + await AsyncStorage.removeItem(USER_KEY); + setUser(null); + } } catch { - // ignore + // Network error — cached user already set above, just mark ready } finally { setReady(true); } @@ -23,5 +53,6 @@ export function useBootAuth() { export async function logout() { await clearTokens(); + await AsyncStorage.removeItem(USER_KEY).catch(() => {}); useAuth.getState().setUser(null); } diff --git a/index.js b/index.js new file mode 100644 index 0000000..ce52c01 --- /dev/null +++ b/index.js @@ -0,0 +1,5 @@ +import 'expo-router/entry'; +import TrackPlayer from 'react-native-track-player'; +import { PlaybackService } from './hooks/PlaybackService'; + +TrackPlayer.registerPlaybackService(() => PlaybackService); diff --git a/package.json b/package.json index f95a331..9978aae 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "burmalda-music", "version": "1.0.0", - "main": "expo-router/entry", + "main": "./index.js", "scripts": { "start": "expo start", "ios": "expo run:ios", @@ -12,7 +12,6 @@ "dependencies": { "expo": "~51.0.28", "expo-router": "~3.5.23", - "expo-av": "~14.0.7", "expo-blur": "~13.0.2", "expo-constants": "~16.0.2", "expo-haptics": "~13.0.1", @@ -31,7 +30,8 @@ "react-native-screens": "3.31.1", "zustand": "^4.5.4", "@react-native-async-storage/async-storage": "1.23.1", - "@react-native-community/slider": "4.5.2" + "@react-native-community/slider": "4.5.2", + "react-native-track-player": "^4.1.1" }, "devDependencies": { "@babel/core": "^7.24.0", diff --git a/services/api.ts b/services/api.ts index 0940533..4845c75 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,7 +1,7 @@ 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'; @@ -21,7 +21,7 @@ export async function clearTokens() { // ── Fetch helper ────────────────────────────────────────────────────────────── -async function apiFetch(path: string, opts: RequestInit = {}): Promise { +export async function apiFetch(path: string, opts: RequestInit = {}): Promise { const token = await getAccessToken(); const headers: HeadersInit = { 'Content-Type': 'application/json', @@ -54,31 +54,34 @@ export async function exchangeCode(code: string): Promise<{ access_token: string // ── Types ───────────────────────────────────────────────────────────────────── export interface Track { - id: number; - title: string; - artist: string; - duration: number; - cover_url: string | null; - stream_url: string; - album?: string; - plays?: number; - liked?: boolean; + id: number; + title: string; + artist: string; + duration: number; + cover_url: string | null; + cdn_url: string; + cdn_url2: string; + filename: string; + album?: string; + play_count?: number; + source?: string; + cdn_synced?: boolean; } export interface Album { - id: number; - title: string; - artist: string; - cover_url: string | null; + id: number; + title: string; + artist: string; + cover_url: string | null; track_count: number; } export interface User { - id: number; - email: string; - username: string | null; + id: number; + email: string; + username: string | null; avatar_url: string | null; - plan: string; + plan: string; } export interface Lyrics { @@ -89,7 +92,7 @@ export interface Lyrics { // ── API calls ───────────────────────────────────────────────────────────────── export async function getTracks(): Promise { - const r = await apiFetch('/api/music/tracks'); + const r = await apiFetch('/api/music/tracks?limit=200'); if (!r.ok) return []; return r.json(); } @@ -97,19 +100,46 @@ export async function getTracks(): Promise { export async function getAlbums(): Promise { const r = await apiFetch('/api/music/albums'); if (!r.ok) return []; - return r.json(); + const data: Array<{ album: string; artist: string; count: number }> = await r.json(); + return data.map((a, i) => ({ + id: i, + title: a.album, + artist: a.artist, + cover_url: null, + track_count: a.count, + })); } export async function getMe(): Promise { - const r = await apiFetch('/api/music/profile/me'); + // Validate token + const r = await apiFetch('/api/auth/me'); if (!r.ok) return null; - return r.json(); + const auth = await r.json(); + + // Get music profile for username / avatar + const pr = await apiFetch('/api/music/profile/me'); + const profile = pr.ok ? await pr.json() : {}; + + return { + id: auth.id, + email: auth.email, + username: profile.username || null, + avatar_url: profile.avatar_url || null, + plan: 'free', // merged with cached plan in useBootAuth + }; } export async function searchTracks(q: string): Promise { - const r = await apiFetch(`/api/music/tracks?q=${encodeURIComponent(q)}`); + const r = await apiFetch('/api/music/tracks?limit=200'); if (!r.ok) return []; - return r.json(); + const all: Track[] = await r.json(); + if (!q.trim()) return all; + const lq = q.toLowerCase(); + return all.filter(t => + t.title.toLowerCase().includes(lq) || + t.artist.toLowerCase().includes(lq) || + (t.album && t.album.toLowerCase().includes(lq)) + ); } export async function fetchLyrics(id: number): Promise { @@ -118,14 +148,15 @@ export async function fetchLyrics(id: number): Promise { return r.json(); } -export function streamUrl(filename: string): string { - return `${BASE}/api/music/stream/${filename}`; -} - -export function coverUrl(filename: string | null): string | null { - if (!filename) return null; - if (filename.startsWith('http')) return filename; - return `${BASE}/api/music/cover/${filename}`; +// cover_url from the server is one of: +// - "https://cdn.nodeshift.space/music/xxx.jpg" (full URL) +// - "/api/music/cover/xxx.jpg" (relative path) +// - "xxx.jpg" (bare filename, legacy) +export function coverUrl(raw: string | null): string | null { + if (!raw) return null; + if (raw.startsWith('http')) return raw; + if (raw.startsWith('/')) return `${BASE}${raw}`; + return `${BASE}/api/music/cover/${raw}`; } // ── LRC parser ────────────────────────────────────────────────────────────────