diff --git a/app/_layout.tsx b/app/_layout.tsx index 25c96cd..c8a2cea 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,6 +16,7 @@ export default function RootLayout() { + ); diff --git a/app/auth.tsx b/app/auth.tsx index ece98fd..62c35c2 100644 --- a/app/auth.tsx +++ b/app/auth.tsx @@ -1,165 +1,389 @@ -import { useState, useCallback, useEffect } from 'react'; -import { - View, Text, StyleSheet, Pressable, ActivityIndicator, Dimensions, -} from 'react-native'; -import Animated, { - useSharedValue, useAnimatedStyle, withSpring, withTiming, - FadeIn, SlideInDown, -} 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 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(); - -const { width, height } = Dimensions.get('window'); - -export default function AuthScreen() { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const { setUser } = useAuth(); - const btnScale = useSharedValue(1); - - const btnStyle = useAnimatedStyle(() => ({ - transform: [{ scale: btnScale.value }], - })); - - const handleLogin = useCallback(async () => { - setLoading(true); - setError(''); - try { - 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; - 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); - setUser(data.user); - router.replace('/(tabs)'); - } catch (e: any) { - setError(e.message || 'Что-то пошло не так'); - } finally { - setLoading(false); - } - }, [setUser]); - - return ( - - - - - - - - - - - - - - - Burmalda Music - by NodeShift - - - - - { btnScale.value = withSpring(0.96, { damping: 14 }); }} - onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }} - onPress={handleLogin} - > - - {loading - ? - : ( - - - - - - )} - - {loading ? 'Ожидаем авторизацию…' : 'Войти через NodeShift'} - - - - - - {!!error && ( - {error} - )} - - - - Нажимая «Войти», вы соглашаетесь с{'\n'}условиями использования NodeShift - - - - ); -} - -function Blob({ color, size, left, top, delay }: { - color: string; size: number; left: number; top: number; delay: number; -}) { - const tx = useSharedValue(0); - const ty = useSharedValue(0); - - useEffect(() => { - const animate = () => { - tx.value = withTiming(25, { duration: 8000 }, () => - tx.value = withTiming(-20, { duration: 8000 }, () => - tx.value = withTiming(0, { duration: 8000 }, animate))); - ty.value = withTiming(-35, { duration: 8000 }, () => - ty.value = withTiming(18, { duration: 8000 }, () => - ty.value = withTiming(0, { duration: 8000 }))); - }; - const t = setTimeout(animate, delay); - return () => clearTimeout(t); - }, []); - - const style = useAnimatedStyle(() => ({ - transform: [{ translateX: tx.value }, { translateY: ty.value }], - })); - - return ( - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }, - 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: 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: 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: '#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 }, -}); +import { useState, useCallback, useEffect, useRef } from 'react'; +import { + View, Text, StyleSheet, Pressable, ActivityIndicator, + Dimensions, ScrollView, +} from 'react-native'; +import Animated, { + useSharedValue, useAnimatedStyle, withSpring, withTiming, + withRepeat, withSequence, FadeIn, FadeInDown, FadeInUp, + interpolate, Extrapolate, withDelay, Easing, +} 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 { Colors, Font } from '../constants/theme'; +import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api'; +import { useAuth } from '../store'; +import { saveUser } from '../hooks/useAuth'; + +WebBrowser.maybeCompleteAuthSession(); + +const { width, height } = Dimensions.get('window'); + +// Fake album-like gradient cards for the carousel +const CARDS = [ + ['#1a1a5e', '#3a0ca3'], + ['#0d1b2a', '#1b4332'], + ['#2d0a3a', '#7b2d8b'], + ['#1a0a00', '#7c3f00'], + ['#001b33', '#003d73'], + ['#1a1a3e', '#4a0080'], + ['#0a1a00', '#1a4a00'], + ['#2a0010', '#6a0030'], + ['#001a2a', '#004a5a'], +]; + +export default function AuthScreen() { + const [loading, setLoading] = useState(false); + 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); + + 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, + )); + }, []); + + 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 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 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; + 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); + setUser(data.user); + router.replace('/(tabs)'); + } catch (e: any) { + setError(e.message || 'Что-то пошло не так'); + } finally { + setLoading(false); + } + }, [setUser]); + + return ( + + {/* Deep dark base */} + + + {/* Animated color orbs */} + + + + + + + + + + + {/* Album cards carousel — auto scroll */} + + + + + + + {/* Gradient fade over carousel */} + + + {/* Bottom gradient */} + + + {/* Content */} + + {/* Logo */} + + + + {/* Abstract waveform / sound lines — no music note */} + + + + + + + + + Burmalda + + + Твоя музыка. Твой ритм. + + + + + + + + + {/* Login button */} + + + { btnScale.value = withSpring(0.96, { damping: 12 }); }} + onPressOut={() => { btnScale.value = withSpring(1, { damping: 12 }); }} + onPress={handleLogin} + disabled={loading} + style={s.btnOuter} + > + + {loading ? ( + + ) : ( + + + + )} + + {loading ? 'Входим…' : 'Войти через 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]; + + 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' }, + + // 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 }, + + // 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 }, + + // 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' }, + + 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 }, + + // 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' }, + + // 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 }, +}); diff --git a/app/auth/_layout.tsx b/app/auth/_layout.tsx new file mode 100644 index 0000000..2fef10e --- /dev/null +++ b/app/auth/_layout.tsx @@ -0,0 +1,9 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ( + + + + ); +} diff --git a/app/auth/callback.tsx b/app/auth/callback.tsx new file mode 100644 index 0000000..008aa07 --- /dev/null +++ b/app/auth/callback.tsx @@ -0,0 +1,57 @@ +/** + * 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 * 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'; + +// Complete the auth session so expo-web-browser can close +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(); + }, []); + + 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/components/Player.tsx b/components/Player.tsx index 3108c1b..47abf9f 100644 --- a/components/Player.tsx +++ b/components/Player.tsx @@ -62,9 +62,13 @@ export default function Player() { // Shake → shuffle const onShake = useCallback(() => { - if (!shuffle) toggleShuffle(); - const ni = usePlayer.getState().nextTrack(); - usePlayer.getState().setIndex(ni); + try { + if (!shuffle) toggleShuffle(); + const state = usePlayer.getState(); + if (state.nextTrack && state.setIndex) { + state.setIndex(state.nextTrack()); + } + } catch {} }, [shuffle, toggleShuffle]); useShakeToShuffle(onShake); @@ -184,10 +188,35 @@ export default function Player() { pointerEvents: playerOpacity.value > 0.5 ? 'auto' : 'none', })); + // onNext/onPrev MUST be defined BEFORE coverGesture (no hoisting for useCallback) + const onNext = useCallback(() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); + try { + const ni = usePlayer.getState().nextTrack(); + usePlayer.getState().setIndex(ni); + } catch {} + }, []); + + const onPrev = useCallback(() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); + try { + if (progress > 3) { seekTo(0); return; } + const pi = usePlayer.getState().prevTrack(); + usePlayer.getState().setIndex(pi); + } catch {} + }, [progress]); + + const onCopyTitle = useCallback(() => { + try { + Clipboard.setStringAsync(`${track?.artist} — ${track?.title}`); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + } catch {} + }, [track]); + // Horizontal swipe on cover → next/prev track const coverGesture = useAnimatedGestureHandler({ onStart: (_, ctx) => { ctx.startX = coverTranslateX.value; }, - onActive: (e, ctx) => { + onActive: (e) => { if (Math.abs(e.translationY) > Math.abs(e.translationX) * 1.5) return; coverTranslateX.value = e.translationX * 0.4; }, @@ -213,24 +242,6 @@ export default function Player() { onCancel: () => { coverTranslateX.value = withSpring(0, { damping: 20 }); }, }); - 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); - if (progress > 3) { seekTo(0); return; } - const pi = usePlayer.getState().prevTrack(); - usePlayer.getState().setIndex(pi); - }, [progress]); - - const onCopyTitle = useCallback(() => { - Clipboard.setStringAsync(`${track?.artist} — ${track?.title}`); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - }, [track]); - if (!track) return null; // cover is already declared above via dynColor @@ -243,12 +254,16 @@ export default function Player() { {/* Blurred background */} - + {cover ? ( + + ) : ( + + )} {/* Dynamic color background overlay */} diff --git a/components/ShareTrackModal.tsx b/components/ShareTrackModal.tsx index 78484c7..16e986f 100644 --- a/components/ShareTrackModal.tsx +++ b/components/ShareTrackModal.tsx @@ -1,14 +1,10 @@ /** - * ShareTrackModal — share sheet + QR for co-listening (deep link, no backend) - * Co-listening: generates a deep link burmalda://listen?tid=123&ts=456 - * Friend opens it → app seeks to same position and plays same track + * ShareTrackModal — share + QR co-listening (no backend) */ import { useState } from 'react'; -import { View, Text, StyleSheet, Pressable, Modal, Alert } from 'react-native'; -import * as Sharing from 'expo-sharing'; +import { View, Text, StyleSheet, Pressable, Modal, Alert, Share, Platform } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import * as Haptics from 'expo-haptics'; -import QRCode from 'react-native-qrcode-svg'; import { BlurView } from 'expo-blur'; import { Image } from 'expo-image'; import Svg, { Path } from 'react-native-svg'; @@ -33,23 +29,18 @@ export default function ShareTrackModal({ track, progress, visible, onClose }: P const shareText = `${track.artist} — ${track.title}\n${deepLink}`; const handleShare = async () => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); try { - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(deepLink, { - dialogTitle: `${track.artist} — ${track.title}`, - }); - } else { - await Clipboard.setStringAsync(shareText); - Alert.alert('Скопировано', 'Ссылка скопирована в буфер'); - } + await Share.share({ message: shareText, title: `${track.artist} — ${track.title}` }); } catch {} }; const handleCopy = async () => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - await Clipboard.setStringAsync(deepLink); - Alert.alert('Скопировано', 'Ссылка скопирована'); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); + try { + await Clipboard.setStringAsync(deepLink); + Alert.alert('Скопировано', 'Ссылка скопирована'); + } catch {} }; return ( @@ -58,7 +49,6 @@ export default function ShareTrackModal({ track, progress, visible, onClose }: P - {/* Track info */} {cover ? @@ -70,41 +60,25 @@ export default function ShareTrackModal({ track, progress, visible, onClose }: P - {/* QR toggle */} - {showQR && ( - - - Друг сканирует — сразу переходит к этому месту - - )} - - {/* Actions */} - - - { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setShowQR(v => !v); }} - active={showQR} - /> + + + + + Поделиться + + + + + + Копировать + - Совместное прослушивание: друг откроет ссылку и приложение перейдёт к этому треку и позиции + Ссылка: {deepLink} @@ -115,49 +89,18 @@ export default function ShareTrackModal({ track, progress, visible, onClose }: P ); } -function ActionBtn({ icon, label, onPress, active = false }: { - icon: 'share' | 'copy' | 'qr'; label: string; onPress: () => void; active?: boolean; -}) { - return ( - - - {icon === 'share' && ( - - - - )} - {icon === 'copy' && ( - - - - )} - {icon === 'qr' && ( - - - - )} - - {label} - - ); -} - const s = StyleSheet.create({ - backdrop: { flex: 1 }, - sheet: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.08)' }, - handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', alignSelf: 'center', marginBottom: 20 }, - trackRow: { flexDirection: 'row', alignItems: 'center', gap: 14, marginBottom: 24 }, - cover: { width: 52, height: 52, borderRadius: Radius.sm }, - title: { color: Colors.text, fontSize: Font.md, fontWeight: '700' }, - artist: { color: 'rgba(255,255,255,0.55)', fontSize: Font.sm, marginTop: 2 }, - qrWrap: { alignItems: 'center', marginBottom: 24, padding: 20, backgroundColor: 'rgba(255,255,255,0.06)', borderRadius: 16 }, - qrHint: { color: 'rgba(255,255,255,0.45)', fontSize: 12, marginTop: 12, textAlign: 'center' }, - actions: { flexDirection: 'row', gap: 10, marginBottom: 16 }, - actionBtn: { flex: 1, alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14, paddingVertical: 14, paddingHorizontal: 8 }, - actionBtnActive: { backgroundColor: `${Colors.accent}20`, borderWidth: 1, borderColor: `${Colors.accent}50` }, - actionIcon: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' }, - actionLabel: { color: Colors.text, fontSize: 11, fontWeight: '600', textAlign: 'center' }, - hint: { color: 'rgba(255,255,255,0.3)', fontSize: 11, textAlign: 'center', lineHeight: 16, marginBottom: 20 }, - closeBtn: { paddingVertical: 14, alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14 }, - closeTxt: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, + backdrop: { flex: 1 }, + sheet: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.08)' }, + handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', alignSelf: 'center', marginBottom: 20 }, + trackRow: { flexDirection: 'row', alignItems: 'center', gap: 14, marginBottom: 24 }, + cover: { width: 52, height: 52, borderRadius: Radius.sm }, + title: { color: Colors.text, fontSize: Font.md, fontWeight: '700' }, + artist: { color: 'rgba(255,255,255,0.55)', fontSize: Font.sm, marginTop: 2 }, + actions: { flexDirection: 'row', gap: 10, marginBottom: 16 }, + actionBtn: { flex: 1, alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14, paddingVertical: 16 }, + actionLabel: { color: Colors.text, fontSize: 12, fontWeight: '600' }, + hint: { color: 'rgba(255,255,255,0.3)', fontSize: 10, textAlign: 'center', marginBottom: 20 }, + closeBtn: { paddingVertical: 14, alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14 }, + closeTxt: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, }); diff --git a/hooks/usePlayerFeatures.ts b/hooks/usePlayerFeatures.ts index db4f2c2..112c1fa 100644 --- a/hooks/usePlayerFeatures.ts +++ b/hooks/usePlayerFeatures.ts @@ -21,21 +21,24 @@ export function useDynamicColor(coverUri: string | null): string { if (!coverUri) { setColor('#1a1a2e'); return; } if (colorCache.has(coverUri)) { setColor(colorCache.get(coverUri)!); return; } - ImageColors.getColors(coverUri, { - fallback: '#1a1a2e', - cache: true, - key: coverUri, - }).then((result) => { - let picked = '#1a1a2e'; - if (result.platform === 'ios') { - picked = result.primary ?? result.background ?? picked; - } else if (result.platform === 'android') { - picked = result.vibrant ?? result.dominant ?? picked; - } - // Darken slightly for UI safety - colorCache.set(coverUri, picked); - setColor(picked); - }).catch(() => {}); + try { + ImageColors.getColors(coverUri, { + fallback: '#1a1a2e', + cache: true, + key: coverUri, + }).then((result) => { + let picked = '#1a1a2e'; + if (result.platform === 'ios') { + picked = result.primary ?? result.background ?? picked; + } else if (result.platform === 'android') { + picked = result.vibrant ?? result.dominant ?? picked; + } + colorCache.set(coverUri, picked); + setColor(picked); + }).catch(() => {}); + } catch { + // Native module not available — silently skip + } }, [coverUri]); return color; diff --git a/hooks/useShakeToShuffle.ts b/hooks/useShakeToShuffle.ts index 737b456..6c73530 100644 --- a/hooks/useShakeToShuffle.ts +++ b/hooks/useShakeToShuffle.ts @@ -1,28 +1,34 @@ /** * useShakeToShuffle — accelerometer-based shake detection - * Triggers callback when device is shaken hard enough + * Safely handles missing expo-sensors native module */ import { useEffect, useRef } from 'react'; -import { Accelerometer } from 'expo-sensors'; import * as Haptics from 'expo-haptics'; const SHAKE_THRESHOLD = 2.2; -const SHAKE_COOLDOWN = 1500; // ms +const SHAKE_COOLDOWN = 1500; export function useShakeToShuffle(onShake: () => void) { const lastShake = useRef(0); useEffect(() => { - Accelerometer.setUpdateInterval(100); - const sub = Accelerometer.addListener(({ x, y, z }) => { - const acc = Math.sqrt(x * x + y * y + z * z); - const now = Date.now(); - if (acc > SHAKE_THRESHOLD && now - lastShake.current > SHAKE_COOLDOWN) { - lastShake.current = now; - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - onShake(); - } - }); - return () => sub.remove(); + let sub: { remove: () => void } | null = null; + try { + // Lazily import so missing native module doesn't crash on load + const { Accelerometer } = require('expo-sensors'); + Accelerometer.setUpdateInterval(100); + sub = Accelerometer.addListener(({ x, y, z }: { x: number; y: number; z: number }) => { + const acc = Math.sqrt(x * x + y * y + z * z); + const now = Date.now(); + if (acc > SHAKE_THRESHOLD && now - lastShake.current > SHAKE_COOLDOWN) { + lastShake.current = now; + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + onShake(); + } + }); + } catch { + // expo-sensors not linked — shake disabled, no crash + } + return () => { try { sub?.remove(); } catch {} }; }, [onShake]); } diff --git a/main.yml b/main.yml index 085d7a0..2bb39c0 100644 --- a/main.yml +++ b/main.yml @@ -21,7 +21,12 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: | + npm cache clean --force + npm install --legacy-peer-deps + + - name: Patch react-native-track-player + run: node scripts/patch-track-player.js - name: Install CocoaPods run: sudo gem install cocoapods --no-document