diff --git a/app.json b/app.json index 82051ea..7d59077 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "NodeShiftMusic", "slug": "nodeshiftmusic", - "version": "1.0.0", + "version": "1.0.7", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "dark", @@ -15,7 +15,7 @@ "ios": { "supportsTablet": false, "bundleIdentifier": "com.nodeshiftmusic", - "buildNumber": "1", + "buildNumber": "7", "infoPlist": { "UIBackgroundModes": ["audio"], "NSMicrophoneUsageDescription": "Не используется", @@ -29,21 +29,10 @@ }, "scheme": "burmalda", "plugins": [ - "expo-router", - [ - "expo-updates", - { - "username": "nodeshift" - } - ] + "expo-router" ], "updates": { - "enabled": true, - "checkAutomatically": "ON_LOAD", - "url": "https://nodeshift.space/api/ios/updates" - }, - "runtimeVersion": { - "policy": "appVersion" + "enabled": false }, "extra": { "apiBase": "https://music.nodeshift.space", diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 9048446..7b8fc80 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -84,7 +84,7 @@ export default function ProfileScreen() { - Burmalda Music · v1.0.0 · by NodeShift + Burmalda Music · v1.0.7 BETA · by NodeShift ); diff --git a/app/_layout.tsx b/app/_layout.tsx index c8a2cea..db49d61 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,41 +1,40 @@ -import { useEffect } from 'react'; -import { Stack } from 'expo-router'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { StatusBar } from 'expo-status-bar'; -import * as Updates from 'expo-updates'; -import { useBootAuth } from '../hooks/useAuth'; -import { Colors } from '../constants/theme'; - -export default function RootLayout() { - useBootAuth(); - useOTAUpdates(); - - return ( - - - - - - - - - ); -} - -function useOTAUpdates() { - useEffect(() => { - if (__DEV__) return; - (async () => { - try { - const check = await Updates.checkForUpdateAsync(); - if (check.isAvailable) { - await Updates.fetchUpdateAsync(); - await Updates.reloadAsync(); - } - // eslint-disable-next-line no-empty - } catch { - // silent fail - } - })(); - }, []); -} +import { useEffect } from 'react'; +import { Stack } from 'expo-router'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { StatusBar } from 'expo-status-bar'; +import { useBootAuth } from '../hooks/useAuth'; +import { Colors } from '../constants/theme'; + +export default function RootLayout() { + useBootAuth(); + useOTAUpdates(); + + return ( + + + + + + + + ); +} + +function useOTAUpdates() { + useEffect(() => { + if (__DEV__) return; + // Lazy import so expo-updates crash doesn't kill the app + (async () => { + try { + const Updates = await import('expo-updates'); + const check = await Updates.checkForUpdateAsync(); + if (check.isAvailable) { + await Updates.fetchUpdateAsync(); + await Updates.reloadAsync(); + } + } catch { + // Silently ignore — OTA is optional + } + })(); + }, []); +} diff --git a/app/auth.tsx b/app/auth.tsx index 62c35c2..9c44a9c 100644 --- a/app/auth.tsx +++ b/app/auth.tsx @@ -5,15 +5,15 @@ import { } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, - withRepeat, withSequence, FadeIn, FadeInDown, FadeInUp, - interpolate, Extrapolate, withDelay, Easing, + withRepeat, withSequence, withDelay, Easing, + 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, Defs, RadialGradient, Stop } from 'react-native-svg'; +import Svg, { Path, Circle } from 'react-native-svg'; import { Colors, Font } from '../constants/theme'; import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api'; import { useAuth } from '../store'; @@ -23,8 +23,7 @@ WebBrowser.maybeCompleteAuthSession(); const { width, height } = Dimensions.get('window'); -// Fake album-like gradient cards for the carousel -const CARDS = [ +const CARD_COLORS: [string, string][] = [ ['#1a1a5e', '#3a0ca3'], ['#0d1b2a', '#1b4332'], ['#2d0a3a', '#7b2d8b'], @@ -34,96 +33,87 @@ const CARDS = [ ['#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(); - // Button animation - const btnScale = useSharedValue(1); - const btnGlow = useSharedValue(0); - - // Orb animations - const orb1X = useSharedValue(0); - const orb1Y = useSharedValue(0); - const orb2X = useSharedValue(0); - const orb2Y = useSharedValue(0); - const orb3X = useSharedValue(0); - - // Title word stagger - const titleOpacity = useSharedValue(0); - const titleY = useSharedValue(30); + 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(() => { - // Orb 1 - slow drift - orb1X.value = withRepeat( - withSequence( - withTiming(40, { duration: 9000, easing: Easing.inOut(Easing.sin) }), - withTiming(-30, { duration: 9000, easing: Easing.inOut(Easing.sin) }), - ), -1, true, - ); - orb1Y.value = withRepeat( - withSequence( - withTiming(-50, { duration: 7000, easing: Easing.inOut(Easing.sin) }), - withTiming(30, { duration: 7000, easing: Easing.inOut(Easing.sin) }), - ), -1, true, - ); - // Orb 2 - orb2X.value = withRepeat( - withSequence( - withTiming(-60, { duration: 11000, easing: Easing.inOut(Easing.sin) }), - withTiming(40, { duration: 11000, easing: Easing.inOut(Easing.sin) }), - ), -1, true, - ); - orb2Y.value = withRepeat( - withSequence( - withTiming(60, { duration: 8000, easing: Easing.inOut(Easing.sin) }), - withTiming(-40, { duration: 8000, easing: Easing.inOut(Easing.sin) }), - ), -1, true, - ); - // Orb 3 - orb3X.value = withRepeat( - withSequence( - withTiming(50, { duration: 13000, easing: Easing.inOut(Easing.sin) }), - withTiming(-35, { duration: 13000, easing: Easing.inOut(Easing.sin) }), - ), -1, true, - ); - - // Title entrance - titleOpacity.value = withDelay(300, withTiming(1, { duration: 900 })); - titleY.value = withDelay(300, withSpring(0, { damping: 18, stiffness: 80 })); - - // Button glow pulse - btnGlow.value = withDelay(1200, withRepeat( - withSequence( - withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.ease) }), - withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.ease) }), - ), -1, false, - )); + 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 orb3Style = useAnimatedStyle(() => ({ - transform: [{ translateX: orb3X.value }], - })); - const titleStyle = useAnimatedStyle(() => ({ - opacity: titleOpacity.value, - transform: [{ translateY: titleY.value }], - })); - const btnStyle = useAnimatedStyle(() => ({ - transform: [{ scale: btnScale.value }], - })); - const glowStyle = useAnimatedStyle(() => ({ - opacity: interpolate(btnGlow.value, [0, 1], [0.35, 0.75], Extrapolate.CLAMP), - transform: [{ scale: interpolate(btnGlow.value, [0, 1], [1, 1.08], Extrapolate.CLAMP) }], - })); + 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(() => {}); @@ -133,17 +123,10 @@ export default function AuthScreen() { 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; - } + if (result.type !== 'success') { setError('Авторизация отменена'); return; } const parsed = Linking.parse(result.url); const code = parsed.queryParams?.code as string | undefined; - if (!code) { - setError((parsed.queryParams?.error as string) || 'Ошибка авторизации'); - return; - } + if (!code) { setError((parsed.queryParams?.error as string) || 'Ошибка авторизации'); return; } const data = await exchangeCode(code); await saveTokens(data.access_token, data.refresh_token); await saveUser(data.user); @@ -158,232 +141,159 @@ export default function AuthScreen() { return ( - {/* Deep dark base */} + {/* Dark background */} + - {/* Animated color orbs */} - - + {/* Orbs */} + + - - - - - + + - {/* Album cards carousel — auto scroll */} - - - - + {/* Scrolling cards — top half */} + + + + - {/* Gradient fade over carousel */} + {/* Gradient covers carousel bottom half */} + - {/* Bottom gradient */} - - - {/* Content */} + {/* Main content */} - {/* Logo */} - - - - {/* Abstract waveform / sound lines — no music note */} - - - - - - + {/* Logo icon */} + + + + + + + + + + + - Burmalda + {/* Brand name */} + + Burmalda + - - Твоя музыка. Твой ритм. + {/* Tagline */} + + «Если я говорю сукин сын и сын шлюхи — это не одно и то же» + + + {/* Feature list */} + + + + - - - - - - - {/* Login button */} - + {/* Button */} + { btnScale.value = withSpring(0.96, { damping: 12 }); }} - onPressOut={() => { btnScale.value = withSpring(1, { damping: 12 }); }} + onPressOut={() => { btnScale.value = withSpring(1, { damping: 12 }); }} onPress={handleLogin} disabled={loading} - style={s.btnOuter} > - {loading ? ( - - ) : ( - - - - )} + {loading + ? + : ( + + + + ) + } {loading ? 'Входим…' : 'Войти через NodeShift'} - - {!!error && ( - {error} - )} - - - Продолжая, вы соглашаетесь с условиями NodeShift - + + {!!error && ( + {error} + )} + + + Продолжая, вы соглашаетесь с условиями NodeShift + ); } -// ── Auto-scrolling card row ──────────────────────────────────────────────────── - -function AutoScrollRow({ reverse, speed }: { reverse: boolean; speed: number }) { - const scrollRef = useRef(null); - const offset = useRef(0); - const maxScroll = useRef(CARDS.length * 90); - - useEffect(() => { - const interval = setInterval(() => { - offset.current = reverse - ? offset.current <= 0 ? maxScroll.current : offset.current - 0.8 - : offset.current >= maxScroll.current ? 0 : offset.current + 0.8; - scrollRef.current?.scrollTo({ x: offset.current, animated: false }); - }, speed / 10); - return () => clearInterval(interval); - }, []); - - const doubled = [...CARDS, ...CARDS, ...CARDS]; - +function FeatureItem({ color, text }: { color: string; text: string }) { return ( - - {doubled.map((colors, i) => ( - - ))} - - ); -} - -function GradientCard({ colors }: { colors: [string, string] }) { - return ( - - ); -} - -// ── Feature row item ────────────────────────────────────────────────────────── - -function FeatureRow({ icon, text, delay }: { icon: string; text: string; delay: number }) { - const icons: Record = { - wave: ( - - - - ), - cloud: ( - - - - ), - heart: ( - - - - ), - }; - - return ( - - {icons[icon]} + + {text} - + ); } -// ── Styles ──────────────────────────────────────────────────────────────────── - -const CARD_W = 76; -const CARD_H = 76; - const s = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#070711' }, + root: { flex: 1 }, - // Orbs - orb: { position: 'absolute', borderRadius: 999 }, - orbInner: { flex: 1, borderRadius: 999 }, - orb1: { width: 340, height: 340, top: -80, left: -100, opacity: 0.22 }, - orb2: { width: 280, height: 280, top: height * 0.25, right: -80, opacity: 0.18 }, - orb3: { width: 200, height: 200, bottom: height * 0.3, left: -60, opacity: 0.14 }, + 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 - carouselWrap: { position: 'absolute', top: 0, left: 0, right: 0, height: height * 0.55, gap: 10, paddingTop: 60, overflow: 'hidden' }, - cardRow: { flexGrow: 0, paddingHorizontal: 8 }, - card: { width: CARD_W, height: CARD_H, borderRadius: 14, marginHorizontal: 5, opacity: 0.7 }, + 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 - content: { flex: 1, justifyContent: 'flex-end', paddingBottom: 48, paddingHorizontal: 28 }, - logoWrap: { alignItems: 'flex-start', marginBottom: 16 }, - logoIconWrap:{ alignSelf: 'flex-start' }, - logoIcon: { width: 64, height: 64, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + content: { flex: 1, justifyContent: 'flex-end', paddingBottom: 50, paddingHorizontal: 28 }, - brand: { fontSize: 48, fontWeight: '800', color: '#fff', letterSpacing: -2, marginBottom: 8, lineHeight: 52 }, - tagline: { fontSize: 18, color: 'rgba(255,255,255,0.55)', fontWeight: '500', marginBottom: 32, letterSpacing: -0.3 }, + 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' }, - // Features - features: { gap: 12, marginBottom: 36 }, - featureRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, - featureIcon: { width: 32, height: 32, borderRadius: 10, backgroundColor: 'rgba(67,97,238,0.15)', alignItems: 'center', justifyContent: 'center' }, - featureText: { color: 'rgba(255,255,255,0.7)', fontSize: 15, fontWeight: '500' }, + 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 }, - // Button - btnArea: { gap: 16 }, - btnOuter: { borderRadius: 18, overflow: 'hidden', shadowColor: '#7b2ff7', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.5, shadowRadius: 24 }, - btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 18, paddingHorizontal: 24 }, - btnText: { color: '#fff', fontSize: 16, fontWeight: '700', letterSpacing: -0.3 }, - error: { color: '#ff6b6b', fontSize: 13, textAlign: 'center' }, - terms: { color: 'rgba(255,255,255,0.28)', fontSize: 12, textAlign: 'center', lineHeight: 18 }, + 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 }, }); diff --git a/app/auth/callback.tsx b/app/auth/callback.tsx index 008aa07..e8c32b1 100644 --- a/app/auth/callback.tsx +++ b/app/auth/callback.tsx @@ -1,57 +1,24 @@ -/** - * app/auth/callback.tsx - * Handles OAuth deep link: burmalda://auth/callback?code=... - * expo-web-browser closes the session, then this page completes the flow. - */ -import { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; -import { useLocalSearchParams, router } from 'expo-router'; +import { useEffect } from 'react'; +import { View, ActivityIndicator } from 'react-native'; import * as WebBrowser from 'expo-web-browser'; -import { Colors, Font } from '../../constants/theme'; -import { exchangeCode, saveTokens } from '../../services/api'; -import { useAuth } from '../../store'; -import { saveUser } from '../../hooks/useAuth'; +import { router } from 'expo-router'; -// Complete the auth session so expo-web-browser can close +// Complete the browser auth session so WebBrowser.openAuthSessionAsync returns WebBrowser.maybeCompleteAuthSession(); export default function AuthCallback() { - const params = useLocalSearchParams<{ code?: string; error?: string }>(); - const { setUser } = useAuth(); - const [status, setStatus] = useState('Завершаем вход…'); - useEffect(() => { - async function finish() { - const code = params.code; - if (!code) { - const msg = params.error || 'Код авторизации отсутствует'; - setStatus(`Ошибка: ${msg}`); - setTimeout(() => router.replace('/auth'), 2000); - return; - } - try { - 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) { - setStatus(`Ошибка: ${e.message || 'Не удалось войти'}`); - setTimeout(() => router.replace('/auth'), 2500); - } - } - finish(); + // The actual code exchange is handled in auth.tsx via openAuthSessionAsync + // This page only shows briefly if opened directly; redirect to auth + const t = setTimeout(() => { + router.replace('/auth'); + }, 500); + return () => clearTimeout(t); }, []); return ( - - - {status} + + ); } - -const s = StyleSheet.create({ - root: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', gap: 20 }, - text: { color: Colors.text, fontSize: Font.md, textAlign: 'center', opacity: 0.7 }, -}); diff --git a/package.json b/package.json index ed12191..694ec0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "burmalda-music", - "version": "1.0.0", + "version": "1.0.7-beta", "main": "./index.js", "scripts": { "start": "expo start",