From 184247a27245c7eeb1a7388a63681e5fd8aaa409 Mon Sep 17 00:00:00 2001 From: Savsis Date: Fri, 8 May 2026 23:16:41 +0500 Subject: [PATCH] Add files via upload --- app/(tabs)/library.tsx | 76 +++++--- app/auth.tsx | 357 +++++++++++++------------------------- components/MiniPlayer.tsx | 10 +- components/Player.tsx | 69 +++++--- 4 files changed, 225 insertions(+), 287 deletions(-) diff --git a/app/(tabs)/library.tsx b/app/(tabs)/library.tsx index 40192f6..1cc5b99 100644 --- a/app/(tabs)/library.tsx +++ b/app/(tabs)/library.tsx @@ -12,8 +12,10 @@ import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } fro import { getTracks, Track, coverUrl } from '../../services/api'; import { usePlayer, useLikes, useHidden, usePlaylists, Playlist } from '../../store'; import { getMyWaveTracks } from '../../services/mywave'; -import TrackDownloadButton from '../../components/DownloadButton'; import WaveAnimation from '../../components/WaveAnimation'; +import { LinearGradient } from 'expo-linear-gradient'; + +const AnimatedGradient = Animated.createAnimatedComponent(LinearGradient); type Tab = 'tracks' | 'playlists' | 'wave'; @@ -205,31 +207,26 @@ export default function LibraryScreen() { contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} showsVerticalScrollIndicator={false} ListHeaderComponent={ - - {/* Animated wave */} + + - + Моя волна - {waveLoading ? 'DeepSeek подбирает треки...' : `${wave.length} треков на основе ваших лайков`} + {waveLoading ? 'DeepSeek подбирает идеальные треки...' : `${wave.length} треков на основе вашего вкуса`} {wave.length > 0 && ( - { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - playTrack(wave, 0); - }} - > - - - - Слушать волну - + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + playTrack(wave, 0); + }} /> )} - + } ListEmptyComponent={ waveLoading @@ -287,6 +284,35 @@ export default function LibraryScreen() { ); } +function PulsePlayButton({ onPress }: { onPress: () => void }) { + const scale = useSharedValue(1); + const glow = useSharedValue(0.4); + + useEffect(() => { + glow.value = withRepeat(withSpring(0.8, { damping: 2 }), -1, true); + }, []); + + const st = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); + const gl = useAnimatedStyle(() => ({ opacity: glow.value })); + + return ( + + + { scale.value = withSpring(0.92, { damping: 15 }); }} + onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} + onPress={onPress} + style={s.playAllBtn} + > + + + + Слушать поток + + + ); +} + function TrackRow({ track, index, liked, onPress, onLike, onLongPress }: { track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void; onLongPress: () => void; @@ -403,13 +429,13 @@ const s = StyleSheet.create({ plCoverFallback: { width: 52, height: 52, backgroundColor: Colors.elevated, borderRadius: Radius.sm, alignItems: 'center', justifyContent: 'center' }, plName: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, plCount: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, - waveHeader: { paddingBottom: 20, alignItems: 'center', overflow: 'hidden', borderRadius: 20, marginBottom: 8, backgroundColor: Colors.surface }, - waveViz: { width: '100%', height: 80, overflow: 'hidden', marginBottom: 0 }, - waveGradientOverlay: { position: 'absolute', top: 0, left: 0, right: 0, height: 80, backgroundColor: 'transparent' }, - waveTitle: { color: Colors.text, fontSize: Font.xl, fontWeight: '800', letterSpacing: -0.3, marginTop: 16 }, - waveSub: { color: Colors.text2, fontSize: Font.sm, marginTop: 4, marginBottom: 16 }, - playAllBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: Colors.accent, paddingHorizontal: 28, paddingVertical: 13, borderRadius: Radius.full }, - playAllText: { color: Colors.bg, fontSize: Font.md, fontWeight: '700' }, + waveHeader: { paddingBottom: 24, paddingTop: 12, alignItems: 'center', overflow: 'hidden', borderRadius: 24, marginBottom: 12, backgroundColor: Colors.surface, borderWidth: 1, borderColor: 'rgba(255,255,255,0.03)' }, + waveViz: { width: '100%', height: 90, overflow: 'hidden', marginBottom: 0 }, + waveGradientOverlay: { position: 'absolute', top: 0, left: 0, right: 0, height: 90, backgroundColor: 'transparent' }, + waveTitle: { color: Colors.text, fontSize: 26, fontWeight: '800', letterSpacing: -0.5, marginTop: 16 }, + waveSub: { color: 'rgba(255,255,255,0.5)', fontSize: Font.sm, marginTop: 4, marginBottom: 20 }, + playAllBtn: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: Colors.accent, paddingHorizontal: 32, paddingVertical: 14, borderRadius: 30 }, + playAllText: { color: '#fff', fontSize: 16, fontWeight: '800', letterSpacing: 0.2 }, modalBg: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }, modalSheet: { backgroundColor: Colors.surface, borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 24, paddingBottom: 40 }, modalTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: 16 }, diff --git a/app/auth.tsx b/app/auth.tsx index 9c44a9c..07a84a6 100644 --- a/app/auth.tsx +++ b/app/auth.tsx @@ -1,20 +1,14 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback } from 'react'; import { - View, Text, StyleSheet, Pressable, ActivityIndicator, - Dimensions, ScrollView, + View, Text, StyleSheet, Pressable, ActivityIndicator, Image, Dimensions } from 'react-native'; -import Animated, { - useSharedValue, useAnimatedStyle, withSpring, withTiming, - withRepeat, withSequence, withDelay, Easing, - FadeIn, FadeInDown, FadeInUp, -} from 'react-native-reanimated'; +import Animated, { FadeIn, FadeInDown, FadeInUp } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; import * as WebBrowser from 'expo-web-browser'; import * as Linking from 'expo-linking'; import { router } from 'expo-router'; import * as Haptics from 'expo-haptics'; -import Svg, { Path, Circle } from 'react-native-svg'; -import { Colors, Font } from '../constants/theme'; +import { Colors } from '../constants/theme'; import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api'; import { useAuth } from '../store'; import { saveUser } from '../hooks/useAuth'; @@ -23,109 +17,22 @@ WebBrowser.maybeCompleteAuthSession(); const { width, height } = Dimensions.get('window'); -const CARD_COLORS: [string, string][] = [ - ['#1a1a5e', '#3a0ca3'], - ['#0d1b2a', '#1b4332'], - ['#2d0a3a', '#7b2d8b'], - ['#1a0a00', '#7c3f00'], - ['#001b33', '#003d73'], - ['#1a1a3e', '#4a0080'], - ['#0a1a00', '#1a4a00'], - ['#2a0010', '#6a0030'], - ['#001a2a', '#004a5a'], - ['#1e0030', '#5e0080'], - ['#002200', '#004400'], - ['#300000', '#700000'], -]; - -function CardRow({ reverse, speed }: { reverse: boolean; speed: number }) { - const ref = useRef(null); - const pos = useRef(0); - const doubled = [...CARD_COLORS, ...CARD_COLORS, ...CARD_COLORS]; - const max = CARD_COLORS.length * 86; - - useEffect(() => { - const id = setInterval(() => { - if (reverse) { - pos.current = pos.current <= 0 ? max : pos.current - 0.7; - } else { - pos.current = pos.current >= max ? 0 : pos.current + 0.7; - } - ref.current?.scrollTo({ x: pos.current, animated: false }); - }, speed); - return () => clearInterval(id); - }, []); - - return ( - - {doubled.map((c, i) => ( - - ))} - - ); -} - -const card: object = { - width: 76, height: 76, borderRadius: 14, marginHorizontal: 5, opacity: 0.65, -}; - export default function AuthScreen() { const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const [error, setError] = useState(''); const { setUser } = useAuth(); - const btnScale = useSharedValue(1); - const orb1y = useSharedValue(0); - const orb2y = useSharedValue(0); - const orb1x = useSharedValue(0); - const orb2x = useSharedValue(0); - const glowOpacity = useSharedValue(0.3); - - useEffect(() => { - orb1y.value = withRepeat(withSequence( - withTiming(-50, { duration: 8000, easing: Easing.inOut(Easing.sin) }), - withTiming(30, { duration: 8000, easing: Easing.inOut(Easing.sin) }), - ), -1, true); - orb1x.value = withRepeat(withSequence( - withTiming(40, { duration: 9000, easing: Easing.inOut(Easing.sin) }), - withTiming(-30, { duration: 9000, easing: Easing.inOut(Easing.sin) }), - ), -1, true); - orb2y.value = withRepeat(withSequence( - withTiming(60, { duration: 10000, easing: Easing.inOut(Easing.sin) }), - withTiming(-40, { duration: 10000, easing: Easing.inOut(Easing.sin) }), - ), -1, true); - orb2x.value = withRepeat(withSequence( - withTiming(-50, { duration: 11000, easing: Easing.inOut(Easing.sin) }), - withTiming(40, { duration: 11000, easing: Easing.inOut(Easing.sin) }), - ), -1, true); - glowOpacity.value = withDelay(800, withRepeat(withSequence( - withTiming(0.8, { duration: 2200, easing: Easing.inOut(Easing.ease) }), - withTiming(0.3, { duration: 2200, easing: Easing.inOut(Easing.ease) }), - ), -1, false)); - }, []); - - const orb1Style = useAnimatedStyle(() => ({ transform: [{ translateX: orb1x.value }, { translateY: orb1y.value }] })); - const orb2Style = useAnimatedStyle(() => ({ transform: [{ translateX: orb2x.value }, { translateY: orb2y.value }] })); - const btnStyle = useAnimatedStyle(() => ({ transform: [{ scale: btnScale.value }] })); - const glowStyle = useAnimatedStyle(() => ({ opacity: glowOpacity.value })); - const handleLogin = useCallback(async () => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); setLoading(true); setError(''); try { - const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); - const url = buildOAuthUrl(state); + const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); + const url = buildOAuthUrl(state); const result = await WebBrowser.openAuthSessionAsync(url, 'burmalda://auth/callback'); if (result.type !== 'success') { setError('Авторизация отменена'); return; } const parsed = Linking.parse(result.url); - const code = parsed.queryParams?.code as string | undefined; + const code = parsed.queryParams?.code as string | undefined; if (!code) { setError((parsed.queryParams?.error as string) || 'Ошибка авторизации'); return; } const data = await exchangeCode(code); await saveTokens(data.access_token, data.refresh_token); @@ -141,116 +48,62 @@ export default function AuthScreen() { return ( - {/* Dark background */} - - - - {/* Orbs */} - - - - - + {/* Sleek dark background */} + + + {/* Modern gradient accent for nodeshiftmusic style */} + + - {/* Scrolling cards — top half */} - - - - - - - {/* Gradient covers carousel bottom half */} - - - - {/* Main content */} - {/* Logo icon */} - - - - - - - - - - - + + - {/* Brand name */} - Burmalda + NodeShift + + + + Музыкальная экосистема - {/* Tagline */} - - «Если я говорю сукин сын и сын шлюхи — это не одно и то же» - - - {/* Feature list */} - - - - - - - {/* Button */} - - - { btnScale.value = withSpring(0.96, { damping: 12 }); }} - onPressOut={() => { btnScale.value = withSpring(1, { damping: 12 }); }} - onPress={handleLogin} - disabled={loading} + + [ + s.btn, + pressed && s.btnPressed + ]} + onPress={handleLogin} + disabled={loading} + > + - - {loading - ? - : ( - - - - ) - } - - {loading ? 'Входим…' : 'Войти через NodeShift'} - - - - + {loading ? ( + + ) : ( + Войти + )} + + {!!error && ( {error} )} - + Продолжая, вы соглашаетесь с условиями NodeShift @@ -258,42 +111,80 @@ export default function AuthScreen() { ); } -function FeatureItem({ color, text }: { color: string; text: string }) { - return ( - - - {text} - - ); -} - const s = StyleSheet.create({ - root: { flex: 1 }, - - orb1: { position: 'absolute', width: 320, height: 320, top: -60, left: -100, borderRadius: 160, overflow: 'hidden', opacity: 0.2 }, - orb2: { position: 'absolute', width: 260, height: 260, top: height * 0.2, right: -80, borderRadius: 130, overflow: 'hidden', opacity: 0.15 }, - orbGrad: { flex: 1 }, - - carousel: { position: 'absolute', top: 0, left: 0, right: 0, height: height * 0.52, gap: 10, paddingTop: 56, overflow: 'hidden' }, - fadeBottom: { position: 'absolute', top: height * 0.35, left: 0, right: 0, height: height * 0.2 }, - fadeTop: { position: 'absolute', top: 0, left: 0, right: 0, height: 80 }, - - content: { flex: 1, justifyContent: 'flex-end', paddingBottom: 50, paddingHorizontal: 28 }, - - logoRow: { marginBottom: 18 }, - logoShadow: { shadowColor: '#7b2ff7', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.6, shadowRadius: 20 }, - logoBox: { width: 62, height: 62, borderRadius: 17, alignItems: 'center', justifyContent: 'center' }, - - brand: { fontSize: 46, fontWeight: '800', color: '#fff', letterSpacing: -2, marginBottom: 10, lineHeight: 50 }, - tagline: { fontSize: 13, color: 'rgba(255,255,255,0.38)', fontWeight: '400', fontStyle: 'italic', marginBottom: 30, lineHeight: 19, letterSpacing: 0.1 }, - - features: { gap: 11, marginBottom: 32 }, - featureRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, - dot: { width: 8, height: 8, borderRadius: 4 }, - featureText:{ color: 'rgba(255,255,255,0.65)', fontSize: 14, fontWeight: '500' }, - - btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 17, paddingHorizontal: 24, borderRadius: 17 }, - btnText: { color: '#fff', fontSize: 16, fontWeight: '700', letterSpacing: -0.3 }, - error: { color: '#ff6b6b', fontSize: 13, textAlign: 'center', marginTop: 12 }, - terms: { color: 'rgba(255,255,255,0.25)', fontSize: 11, textAlign: 'center', marginTop: 16, lineHeight: 17 }, + root: { flex: 1 }, + content: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, paddingBottom: 40 }, + logoContainer: { + width: 140, + height: 140, + marginBottom: 24, + shadowColor: '#4361ee', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.3, + shadowRadius: 24, + elevation: 10, + }, + logo: { + width: '100%', + height: '100%', + borderRadius: 35, + }, + brand: { + fontSize: 36, + fontWeight: '800', + color: '#ffffff', + letterSpacing: -1, + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: 'rgba(255, 255, 255, 0.5)', + marginBottom: 56, + fontWeight: '500', + letterSpacing: 0.5, + }, + btnWrapper: { + width: '100%', + maxWidth: 320, + shadowColor: '#4361ee', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 12, + elevation: 5, + }, + btn: { + height: 56, + borderRadius: 16, + overflow: 'hidden', + }, + btnGradient: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + btnPressed: { + opacity: 0.9, + transform: [{ scale: 0.97 }], + }, + btnText: { + color: '#ffffff', + fontSize: 17, + fontWeight: '700', + letterSpacing: 0.3, + }, + error: { + color: '#ff4757', + fontSize: 14, + textAlign: 'center', + marginTop: 20, + fontWeight: '500', + }, + terms: { + position: 'absolute', + bottom: 40, + color: 'rgba(255, 255, 255, 0.3)', + fontSize: 12, + textAlign: 'center', + }, }); diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index eb4ae00..17bacbb 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -37,10 +37,12 @@ export default function MiniPlayer() { ctx.start = translateY.value; }, onActive: (e) => { - if (e.translationY > 0) return; // block downward - const dy = Math.max(e.translationY, -100); - translateY.value = dy; - opacity.value = interpolate(dy, [-100, 0], [0.6, 1], Extrapolate.CLAMP); + let dy = e.translationY; + if (dy > 0) { + dy = dy * 0.15; // rubber band resistance when dragging down + } + translateY.value = Math.max(dy, -150); + opacity.value = interpolate(dy, [-100, 0], [0.5, 1], Extrapolate.CLAMP); }, onEnd: (e) => { const shouldOpen = e.translationY < -40 || e.velocityY < -600; diff --git a/components/Player.tsx b/components/Player.tsx index 47abf9f..b3a049b 100644 --- a/components/Player.tsx +++ b/components/Player.tsx @@ -138,10 +138,13 @@ export default function Player() { }, onActive: (e, ctx) => { const dy = e.translationY; - if (dy <= 0) return; - // Rubber band resistance - const resistance = 1 - Math.min(dy / (height * 0.9), 0.85); - translateY.value = ctx.start + dy * resistance; + if (dy <= 0) { + // slight resistance when pulling up + translateY.value = ctx.start + dy * 0.2; + return; + } + // 1:1 tracking for smooth dismissal + translateY.value = ctx.start + dy; }, onEnd: (e) => { const shouldDismiss = @@ -218,24 +221,29 @@ export default function Player() { onStart: (_, ctx) => { ctx.startX = coverTranslateX.value; }, onActive: (e) => { if (Math.abs(e.translationY) > Math.abs(e.translationX) * 1.5) return; - coverTranslateX.value = e.translationX * 0.4; + // 1:1 tracking for smooth feeling, scale down slightly + coverTranslateX.value = e.translationX * 0.9; + coverScale.value = interpolate(Math.abs(e.translationX), [0, width], [1, 0.85], Extrapolate.CLAMP); }, onEnd: (e) => { - const THRESH = 60; + const THRESH = 90; // slightly higher threshold to prevent accidental skips if (e.translationX < -THRESH) { - coverTranslateX.value = withTiming(-width, { duration: 220 }, () => { + coverTranslateX.value = withTiming(-width, { duration: 250, easing: Easing.out(Easing.cubic) }, () => { runOnJS(onNext)(); coverTranslateX.value = width; - coverTranslateX.value = withSpring(0, { damping: 20, stiffness: 180 }); + coverTranslateX.value = withSpring(0, { damping: 22, stiffness: 180 }); + coverScale.value = withSpring(1); }); } else if (e.translationX > THRESH) { - coverTranslateX.value = withTiming(width, { duration: 220 }, () => { + coverTranslateX.value = withTiming(width, { duration: 250, easing: Easing.out(Easing.cubic) }, () => { runOnJS(onPrev)(); coverTranslateX.value = -width; - coverTranslateX.value = withSpring(0, { damping: 20, stiffness: 180 }); + coverTranslateX.value = withSpring(0, { damping: 22, stiffness: 180 }); + coverScale.value = withSpring(1); }); } else { coverTranslateX.value = withSpring(0, { damping: 20, stiffness: 220 }); + coverScale.value = withSpring(1); } }, onFail: () => { coverTranslateX.value = withSpring(0, { damping: 20 }); }, @@ -447,17 +455,28 @@ export default function Player() { {/* Bottom row */} - 0 || plainLyrics) && s.lyrBtnActive]} - onPress={loadLyrics} - > - - - - - {lyricsLoading ? 'Загрузка...' : 'Текст песни'} - - + + 0 || plainLyrics) && s.lyrBtnActive]}> + + + + + {lyricsLoading ? 'Загрузка...' : 'Текст'} + + + + + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + Alert.alert('VST Эффекты', 'Плагины для обработки звука будут добавлены в следующем обновлении.'); + }}> + + + + + VST / Звук + + @@ -600,14 +619,14 @@ const s = StyleSheet.create({ backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.4, shadowRadius: 16, }, - bottomRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 4 }, + bottomRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 8, gap: 12 }, lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, - backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 18, paddingVertical: 10, - borderRadius: 20, borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)', + backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 20, paddingVertical: 12, + borderRadius: 24, borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)', }, lyrBtnActive: { borderColor: `${Colors.accent}50`, backgroundColor: `${Colors.accent}18` }, - lyrBtnText: { color: 'rgba(255,255,255,0.55)', fontSize: 13, fontWeight: '600' }, + lyrBtnText: { color: 'rgba(255,255,255,0.55)', fontSize: 14, fontWeight: '600' }, repBadge: { position: 'absolute', top: -3, right: -5, width: 11, height: 11, borderRadius: 6, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center',