Add files via upload
This commit is contained in:
parent
6050fed0d8
commit
8439481e34
9 changed files with 580 additions and 317 deletions
|
|
@ -16,6 +16,7 @@ export default function RootLayout() {
|
||||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.bg } }}>
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.bg } }}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="auth" options={{ presentation: 'fullScreenModal' }} />
|
<Stack.Screen name="auth" options={{ presentation: 'fullScreenModal' }} />
|
||||||
|
<Stack.Screen name="auth/callback" options={{ presentation: 'fullScreenModal' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
382
app/auth.tsx
382
app/auth.tsx
|
|
@ -1,17 +1,20 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, Pressable, ActivityIndicator, Dimensions,
|
View, Text, StyleSheet, Pressable, ActivityIndicator,
|
||||||
|
Dimensions, ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue, useAnimatedStyle, withSpring, withTiming,
|
useSharedValue, useAnimatedStyle, withSpring, withTiming,
|
||||||
FadeIn, SlideInDown,
|
withRepeat, withSequence, FadeIn, FadeInDown, FadeInUp,
|
||||||
|
interpolate, Extrapolate, withDelay, Easing,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import * as Linking from 'expo-linking';
|
import * as Linking from 'expo-linking';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import Svg, { Path, Circle, Polyline, Line } from 'react-native-svg';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Colors, Font, Radius, Spacing } from '../constants/theme';
|
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 { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api';
|
||||||
import { useAuth } from '../store';
|
import { useAuth } from '../store';
|
||||||
import { saveUser } from '../hooks/useAuth';
|
import { saveUser } from '../hooks/useAuth';
|
||||||
|
|
@ -20,17 +23,110 @@ WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
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() {
|
export default function AuthScreen() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { setUser } = useAuth();
|
const { setUser } = useAuth();
|
||||||
const btnScale = useSharedValue(1);
|
|
||||||
|
|
||||||
|
// 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(() => ({
|
const btnStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ scale: btnScale.value }],
|
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 () => {
|
const handleLogin = useCallback(async () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
|
@ -61,105 +157,233 @@ export default function AuthScreen() {
|
||||||
}, [setUser]);
|
}, [setUser]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={s.root}>
|
||||||
<Blob color={Colors.accent} size={420} left={-80} top={-100} delay={0} />
|
{/* Deep dark base */}
|
||||||
<Blob color="#6c63ff" size={320} left={width - 200} top={height * 0.55} delay={2000} />
|
<LinearGradient
|
||||||
<Blob color={Colors.accent} size={240} left={width * 0.3} top={height - 100} delay={4000} />
|
colors={['#070711', '#0d0d1f', '#070711']}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
|
||||||
<Animated.View style={styles.card} entering={FadeIn.duration(600)}>
|
{/* Animated color orbs */}
|
||||||
<Animated.View entering={SlideInDown.delay(100).springify()} style={styles.logoWrap}>
|
<Animated.View style={[s.orb, s.orb1, orb1Style]} pointerEvents="none">
|
||||||
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.logoIcon}>
|
<LinearGradient colors={['#3a0ca3', '#4361ee']} style={s.orbInner} />
|
||||||
<Svg width={38} height={38} viewBox="0 0 24 24" fill="none">
|
</Animated.View>
|
||||||
<Path d="M9 18V5l12-2v13" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
<Animated.View style={[s.orb, s.orb2, orb2Style]} pointerEvents="none">
|
||||||
<Circle cx={6} cy={18} r={3} fill="#fff" />
|
<LinearGradient colors={['#7b2ff7', '#e040fb']} style={s.orbInner} />
|
||||||
<Circle cx={18} cy={16} r={3} fill="#fff" />
|
</Animated.View>
|
||||||
</Svg>
|
<Animated.View style={[s.orb, s.orb3, orb3Style]} pointerEvents="none">
|
||||||
</LinearGradient>
|
<LinearGradient colors={['#00b4d8', '#0077b6']} style={s.orbInner} />
|
||||||
<Text style={styles.logoTitle}>Burmalda Music</Text>
|
</Animated.View>
|
||||||
<Text style={styles.logoSub}>by NodeShift</Text>
|
|
||||||
|
{/* Album cards carousel — auto scroll */}
|
||||||
|
<View style={s.carouselWrap} pointerEvents="none">
|
||||||
|
<AutoScrollRow reverse={false} speed={35} />
|
||||||
|
<AutoScrollRow reverse={true} speed={28} />
|
||||||
|
<AutoScrollRow reverse={false} speed={40} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Gradient fade over carousel */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#070711', 'transparent', 'transparent', '#070711']}
|
||||||
|
locations={[0, 0.15, 0.75, 1]}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom gradient */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', '#070711']}
|
||||||
|
style={[StyleSheet.absoluteFill, { top: height * 0.45 }]}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={s.content}>
|
||||||
|
{/* Logo */}
|
||||||
|
<Animated.View style={[s.logoWrap, titleStyle]}>
|
||||||
|
<Animated.View style={[s.logoIconWrap, glowStyle]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#4361ee', '#7b2ff7', '#e040fb']}
|
||||||
|
start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }}
|
||||||
|
style={s.logoIcon}
|
||||||
|
>
|
||||||
|
{/* Abstract waveform / sound lines — no music note */}
|
||||||
|
<Svg width={34} height={34} viewBox="0 0 34 34" fill="none">
|
||||||
|
<Path d="M4 17 Q8 8, 12 17 Q16 26, 20 17 Q24 8, 30 17"
|
||||||
|
stroke="#fff" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
<Circle cx={17} cy={17} r={2.5} fill="rgba(255,255,255,0.6)" />
|
||||||
|
</Svg>
|
||||||
|
</LinearGradient>
|
||||||
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View entering={SlideInDown.delay(200).springify()} style={{ width: '100%' }}>
|
<Animated.Text style={[s.brand, titleStyle]}>Burmalda</Animated.Text>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.delay(500).springify()}>
|
||||||
|
<Text style={s.tagline}>Твоя музыка. Твой ритм.</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInUp.delay(700).springify()} style={s.features}>
|
||||||
|
<FeatureRow icon="wave" text="Умные рекомендации" delay={800} />
|
||||||
|
<FeatureRow icon="cloud" text="Оффлайн доступ" delay={950} />
|
||||||
|
<FeatureRow icon="heart" text="Ваша волна" delay={1100} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Login button */}
|
||||||
|
<Animated.View entering={FadeInUp.delay(900).springify()} style={s.btnArea}>
|
||||||
<Animated.View style={btnStyle}>
|
<Animated.View style={btnStyle}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.btnWrap}
|
onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 12 }); }}
|
||||||
disabled={loading}
|
onPressOut={() => { btnScale.value = withSpring(1, { damping: 12 }); }}
|
||||||
onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 14 }); }}
|
|
||||||
onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }}
|
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
style={s.btnOuter}
|
||||||
>
|
>
|
||||||
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.btn}>
|
<LinearGradient
|
||||||
{loading
|
colors={['#4361ee', '#7b2ff7', '#e040fb']}
|
||||||
? <ActivityIndicator color="#fff" size="small" />
|
start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }}
|
||||||
: (
|
style={s.btn}
|
||||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
>
|
||||||
<Path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
{loading ? (
|
||||||
<Polyline points="8 17 12 13 16 17" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
<Line x1="12" y1="13" x2="12" y2="21" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
) : (
|
||||||
</Svg>
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||||
)}
|
<Path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"
|
||||||
<Text style={styles.btnText}>
|
stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
{loading ? 'Ожидаем авторизацию…' : 'Войти через NodeShift'}
|
</Svg>
|
||||||
|
)}
|
||||||
|
<Text style={s.btnText}>
|
||||||
|
{loading ? 'Входим…' : 'Войти через NodeShift'}
|
||||||
</Text>
|
</Text>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{!!error && (
|
{!!error && (
|
||||||
<Animated.Text entering={FadeIn} style={styles.error}>{error}</Animated.Text>
|
<Animated.Text entering={FadeIn} style={s.error}>{error}</Animated.Text>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<Animated.Text entering={FadeIn.delay(300)} style={styles.footer}>
|
<Animated.Text entering={FadeIn.delay(1200)} style={s.terms}>
|
||||||
Нажимая «Войти», вы соглашаетесь с{'\n'}условиями использования NodeShift
|
Продолжая, вы соглашаетесь с условиями NodeShift
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Blob({ color, size, left, top, delay }: {
|
// ── Auto-scrolling card row ────────────────────────────────────────────────────
|
||||||
color: string; size: number; left: number; top: number; delay: number;
|
|
||||||
}) {
|
function AutoScrollRow({ reverse, speed }: { reverse: boolean; speed: number }) {
|
||||||
const tx = useSharedValue(0);
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
const ty = useSharedValue(0);
|
const offset = useRef(0);
|
||||||
|
const maxScroll = useRef(CARDS.length * 90);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const animate = () => {
|
const interval = setInterval(() => {
|
||||||
tx.value = withTiming(25, { duration: 8000 }, () =>
|
offset.current = reverse
|
||||||
tx.value = withTiming(-20, { duration: 8000 }, () =>
|
? offset.current <= 0 ? maxScroll.current : offset.current - 0.8
|
||||||
tx.value = withTiming(0, { duration: 8000 }, animate)));
|
: offset.current >= maxScroll.current ? 0 : offset.current + 0.8;
|
||||||
ty.value = withTiming(-35, { duration: 8000 }, () =>
|
scrollRef.current?.scrollTo({ x: offset.current, animated: false });
|
||||||
ty.value = withTiming(18, { duration: 8000 }, () =>
|
}, speed / 10);
|
||||||
ty.value = withTiming(0, { duration: 8000 })));
|
return () => clearInterval(interval);
|
||||||
};
|
|
||||||
const t = setTimeout(animate, delay);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const style = useAnimatedStyle(() => ({
|
const doubled = [...CARDS, ...CARDS, ...CARDS];
|
||||||
transform: [{ translateX: tx.value }, { translateY: ty.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.blob, style, {
|
<ScrollView
|
||||||
width: size, height: size, borderRadius: size / 2,
|
ref={scrollRef}
|
||||||
backgroundColor: color, left, top,
|
horizontal
|
||||||
}]} />
|
scrollEnabled={false}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={s.cardRow}
|
||||||
|
>
|
||||||
|
{doubled.map((colors, i) => (
|
||||||
|
<GradientCard key={i} colors={colors as [string, string]} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function GradientCard({ colors }: { colors: [string, string] }) {
|
||||||
container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
return (
|
||||||
blob: { position: 'absolute', opacity: 0.12 },
|
<LinearGradient colors={colors} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={s.card} />
|
||||||
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 },
|
// ── Feature row item ──────────────────────────────────────────────────────────
|
||||||
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 },
|
function FeatureRow({ icon, text, delay }: { icon: string; text: string; delay: number }) {
|
||||||
btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 },
|
const icons: Record<string, JSX.Element> = {
|
||||||
btnText: { color: '#fff', fontSize: Font.md, fontWeight: '700' },
|
wave: (
|
||||||
error: { color: Colors.red, fontSize: Font.sm, textAlign: 'center', marginTop: Spacing.sm },
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
|
||||||
footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 },
|
<Path d="M2 12 Q5 4, 8 12 Q11 20, 14 12 Q17 4, 22 12"
|
||||||
|
stroke={Colors.accent} strokeWidth={2} strokeLinecap="round" fill="none" />
|
||||||
|
</Svg>
|
||||||
|
),
|
||||||
|
cloud: (
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"
|
||||||
|
stroke={Colors.accent} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
),
|
||||||
|
heart: (
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 0 0 0-7.78z"
|
||||||
|
stroke={Colors.accent} strokeWidth={2} strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(delay).springify()} style={s.featureRow}>
|
||||||
|
<View style={s.featureIcon}>{icons[icon]}</View>
|
||||||
|
<Text style={s.featureText}>{text}</Text>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
9
app/auth/_layout.tsx
Normal file
9
app/auth/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="callback" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
app/auth/callback.tsx
Normal file
57
app/auth/callback.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<View style={s.root}>
|
||||||
|
<ActivityIndicator size="large" color={Colors.accent} />
|
||||||
|
<Text style={s.text}>{status}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
@ -62,9 +62,13 @@ export default function Player() {
|
||||||
|
|
||||||
// Shake → shuffle
|
// Shake → shuffle
|
||||||
const onShake = useCallback(() => {
|
const onShake = useCallback(() => {
|
||||||
if (!shuffle) toggleShuffle();
|
try {
|
||||||
const ni = usePlayer.getState().nextTrack();
|
if (!shuffle) toggleShuffle();
|
||||||
usePlayer.getState().setIndex(ni);
|
const state = usePlayer.getState();
|
||||||
|
if (state.nextTrack && state.setIndex) {
|
||||||
|
state.setIndex(state.nextTrack());
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}, [shuffle, toggleShuffle]);
|
}, [shuffle, toggleShuffle]);
|
||||||
useShakeToShuffle(onShake);
|
useShakeToShuffle(onShake);
|
||||||
|
|
||||||
|
|
@ -184,10 +188,35 @@ export default function Player() {
|
||||||
pointerEvents: playerOpacity.value > 0.5 ? 'auto' : 'none',
|
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
|
// Horizontal swipe on cover → next/prev track
|
||||||
const coverGesture = useAnimatedGestureHandler<any, { startX: number }>({
|
const coverGesture = useAnimatedGestureHandler<any, { startX: number }>({
|
||||||
onStart: (_, ctx) => { ctx.startX = coverTranslateX.value; },
|
onStart: (_, ctx) => { ctx.startX = coverTranslateX.value; },
|
||||||
onActive: (e, ctx) => {
|
onActive: (e) => {
|
||||||
if (Math.abs(e.translationY) > Math.abs(e.translationX) * 1.5) return;
|
if (Math.abs(e.translationY) > Math.abs(e.translationX) * 1.5) return;
|
||||||
coverTranslateX.value = e.translationX * 0.4;
|
coverTranslateX.value = e.translationX * 0.4;
|
||||||
},
|
},
|
||||||
|
|
@ -213,24 +242,6 @@ export default function Player() {
|
||||||
onCancel: () => { coverTranslateX.value = withSpring(0, { damping: 20 }); },
|
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;
|
if (!track) return null;
|
||||||
|
|
||||||
// cover is already declared above via dynColor
|
// cover is already declared above via dynColor
|
||||||
|
|
@ -243,12 +254,16 @@ export default function Player() {
|
||||||
<StatusBar barStyle="light-content" />
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
||||||
{/* Blurred background */}
|
{/* Blurred background */}
|
||||||
<Image
|
{cover ? (
|
||||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
<Image
|
||||||
style={StyleSheet.absoluteFill}
|
source={{ uri: cover }}
|
||||||
contentFit="cover"
|
style={StyleSheet.absoluteFill}
|
||||||
blurRadius={80}
|
contentFit="cover"
|
||||||
/>
|
blurRadius={80}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: '#0a0a1a' }]} />
|
||||||
|
)}
|
||||||
{/* Dynamic color background overlay */}
|
{/* Dynamic color background overlay */}
|
||||||
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
|
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
|
||||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: dynColor, opacity: 0.35 }]} />
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: dynColor, opacity: 0.35 }]} />
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* ShareTrackModal — share sheet + QR for co-listening (deep link, no backend)
|
* ShareTrackModal — share + QR co-listening (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
|
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable, Modal, Alert } from 'react-native';
|
import { View, Text, StyleSheet, Pressable, Modal, Alert, Share, Platform } from 'react-native';
|
||||||
import * as Sharing from 'expo-sharing';
|
|
||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import QRCode from 'react-native-qrcode-svg';
|
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
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 shareText = `${track.artist} — ${track.title}\n${deepLink}`;
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||||
try {
|
try {
|
||||||
if (await Sharing.isAvailableAsync()) {
|
await Share.share({ message: shareText, title: `${track.artist} — ${track.title}` });
|
||||||
await Sharing.shareAsync(deepLink, {
|
|
||||||
dialogTitle: `${track.artist} — ${track.title}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await Clipboard.setStringAsync(shareText);
|
|
||||||
Alert.alert('Скопировано', 'Ссылка скопирована в буфер');
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||||
await Clipboard.setStringAsync(deepLink);
|
try {
|
||||||
Alert.alert('Скопировано', 'Ссылка скопирована');
|
await Clipboard.setStringAsync(deepLink);
|
||||||
|
Alert.alert('Скопировано', 'Ссылка скопирована');
|
||||||
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -58,7 +49,6 @@ export default function ShareTrackModal({ track, progress, visible, onClose }: P
|
||||||
<BlurView intensity={40} tint="dark" style={s.sheet}>
|
<BlurView intensity={40} tint="dark" style={s.sheet}>
|
||||||
<View style={s.handle} />
|
<View style={s.handle} />
|
||||||
|
|
||||||
{/* Track info */}
|
|
||||||
<View style={s.trackRow}>
|
<View style={s.trackRow}>
|
||||||
{cover
|
{cover
|
||||||
? <Image source={{ uri: cover }} style={s.cover} contentFit="cover" />
|
? <Image source={{ uri: cover }} style={s.cover} contentFit="cover" />
|
||||||
|
|
@ -70,41 +60,25 @@ export default function ShareTrackModal({ track, progress, visible, onClose }: P
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* QR toggle */}
|
|
||||||
{showQR && (
|
|
||||||
<View style={s.qrWrap}>
|
|
||||||
<QRCode
|
|
||||||
value={deepLink}
|
|
||||||
size={180}
|
|
||||||
backgroundColor="transparent"
|
|
||||||
color={Colors.text}
|
|
||||||
/>
|
|
||||||
<Text style={s.qrHint}>Друг сканирует — сразу переходит к этому месту</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<View style={s.actions}>
|
<View style={s.actions}>
|
||||||
<ActionBtn
|
<Pressable style={s.actionBtn} onPress={handleShare}>
|
||||||
icon="share"
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
label="Поделиться"
|
<Path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"
|
||||||
onPress={handleShare}
|
stroke={Colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
/>
|
</Svg>
|
||||||
<ActionBtn
|
<Text style={s.actionLabel}>Поделиться</Text>
|
||||||
icon="copy"
|
</Pressable>
|
||||||
label="Скопировать ссылку"
|
<Pressable style={s.actionBtn} onPress={handleCopy}>
|
||||||
onPress={handleCopy}
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
/>
|
<Path d="M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2M16 8h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-2"
|
||||||
<ActionBtn
|
stroke={Colors.text} strokeWidth={2} strokeLinecap="round" />
|
||||||
icon="qr"
|
</Svg>
|
||||||
label={showQR ? 'Скрыть QR' : 'QR-код'}
|
<Text style={s.actionLabel}>Копировать</Text>
|
||||||
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setShowQR(v => !v); }}
|
</Pressable>
|
||||||
active={showQR}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={s.hint}>
|
<Text style={s.hint}>
|
||||||
Совместное прослушивание: друг откроет ссылку и приложение перейдёт к этому треку и позиции
|
Ссылка: {deepLink}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Pressable style={s.closeBtn} onPress={onClose}>
|
<Pressable style={s.closeBtn} onPress={onClose}>
|
||||||
|
|
@ -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 (
|
|
||||||
<Pressable onPress={onPress} style={[s.actionBtn, active && s.actionBtnActive]}>
|
|
||||||
<View style={s.actionIcon}>
|
|
||||||
{icon === 'share' && (
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
|
||||||
<Path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13" stroke={active ? Colors.accent : Colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</Svg>
|
|
||||||
)}
|
|
||||||
{icon === 'copy' && (
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
|
||||||
<Path d="M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2M16 8h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-2" stroke={active ? Colors.accent : Colors.text} strokeWidth={2} strokeLinecap="round" />
|
|
||||||
</Svg>
|
|
||||||
)}
|
|
||||||
{icon === 'qr' && (
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
|
||||||
<Path d="M3 3h6v6H3zM15 3h6v6h-6zM3 15h6v6H3zM15 15h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM15 19h2v2h-2zM19 19h2v2h-2z" stroke={active ? Colors.accent : Colors.text} strokeWidth={1.8} strokeLinecap="round" />
|
|
||||||
</Svg>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Text style={[s.actionLabel, active && { color: Colors.accent }]}>{label}</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
backdrop: { flex: 1 },
|
backdrop: { flex: 1 },
|
||||||
sheet: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.08)' },
|
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 },
|
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 },
|
trackRow: { flexDirection: 'row', alignItems: 'center', gap: 14, marginBottom: 24 },
|
||||||
cover: { width: 52, height: 52, borderRadius: Radius.sm },
|
cover: { width: 52, height: 52, borderRadius: Radius.sm },
|
||||||
title: { color: Colors.text, fontSize: Font.md, fontWeight: '700' },
|
title: { color: Colors.text, fontSize: Font.md, fontWeight: '700' },
|
||||||
artist: { color: 'rgba(255,255,255,0.55)', fontSize: Font.sm, marginTop: 2 },
|
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 },
|
actions: { flexDirection: 'row', gap: 10, marginBottom: 16 },
|
||||||
qrHint: { color: 'rgba(255,255,255,0.45)', fontSize: 12, marginTop: 12, textAlign: 'center' },
|
actionBtn: { flex: 1, alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14, paddingVertical: 16 },
|
||||||
actions: { flexDirection: 'row', gap: 10, marginBottom: 16 },
|
actionLabel: { color: Colors.text, fontSize: 12, fontWeight: '600' },
|
||||||
actionBtn: { flex: 1, alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14, paddingVertical: 14, paddingHorizontal: 8 },
|
hint: { color: 'rgba(255,255,255,0.3)', fontSize: 10, textAlign: 'center', marginBottom: 20 },
|
||||||
actionBtnActive: { backgroundColor: `${Colors.accent}20`, borderWidth: 1, borderColor: `${Colors.accent}50` },
|
closeBtn: { paddingVertical: 14, alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14 },
|
||||||
actionIcon: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
closeTxt: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
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' },
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,21 +21,24 @@ export function useDynamicColor(coverUri: string | null): string {
|
||||||
if (!coverUri) { setColor('#1a1a2e'); return; }
|
if (!coverUri) { setColor('#1a1a2e'); return; }
|
||||||
if (colorCache.has(coverUri)) { setColor(colorCache.get(coverUri)!); return; }
|
if (colorCache.has(coverUri)) { setColor(colorCache.get(coverUri)!); return; }
|
||||||
|
|
||||||
ImageColors.getColors(coverUri, {
|
try {
|
||||||
fallback: '#1a1a2e',
|
ImageColors.getColors(coverUri, {
|
||||||
cache: true,
|
fallback: '#1a1a2e',
|
||||||
key: coverUri,
|
cache: true,
|
||||||
}).then((result) => {
|
key: coverUri,
|
||||||
let picked = '#1a1a2e';
|
}).then((result) => {
|
||||||
if (result.platform === 'ios') {
|
let picked = '#1a1a2e';
|
||||||
picked = result.primary ?? result.background ?? picked;
|
if (result.platform === 'ios') {
|
||||||
} else if (result.platform === 'android') {
|
picked = result.primary ?? result.background ?? picked;
|
||||||
picked = result.vibrant ?? result.dominant ?? picked;
|
} else if (result.platform === 'android') {
|
||||||
}
|
picked = result.vibrant ?? result.dominant ?? picked;
|
||||||
// Darken slightly for UI safety
|
}
|
||||||
colorCache.set(coverUri, picked);
|
colorCache.set(coverUri, picked);
|
||||||
setColor(picked);
|
setColor(picked);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// Native module not available — silently skip
|
||||||
|
}
|
||||||
}, [coverUri]);
|
}, [coverUri]);
|
||||||
|
|
||||||
return color;
|
return color;
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,34 @@
|
||||||
/**
|
/**
|
||||||
* useShakeToShuffle — accelerometer-based shake detection
|
* 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 { useEffect, useRef } from 'react';
|
||||||
import { Accelerometer } from 'expo-sensors';
|
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
const SHAKE_THRESHOLD = 2.2;
|
const SHAKE_THRESHOLD = 2.2;
|
||||||
const SHAKE_COOLDOWN = 1500; // ms
|
const SHAKE_COOLDOWN = 1500;
|
||||||
|
|
||||||
export function useShakeToShuffle(onShake: () => void) {
|
export function useShakeToShuffle(onShake: () => void) {
|
||||||
const lastShake = useRef(0);
|
const lastShake = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Accelerometer.setUpdateInterval(100);
|
let sub: { remove: () => void } | null = null;
|
||||||
const sub = Accelerometer.addListener(({ x, y, z }) => {
|
try {
|
||||||
const acc = Math.sqrt(x * x + y * y + z * z);
|
// Lazily import so missing native module doesn't crash on load
|
||||||
const now = Date.now();
|
const { Accelerometer } = require('expo-sensors');
|
||||||
if (acc > SHAKE_THRESHOLD && now - lastShake.current > SHAKE_COOLDOWN) {
|
Accelerometer.setUpdateInterval(100);
|
||||||
lastShake.current = now;
|
sub = Accelerometer.addListener(({ x, y, z }: { x: number; y: number; z: number }) => {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
const acc = Math.sqrt(x * x + y * y + z * z);
|
||||||
onShake();
|
const now = Date.now();
|
||||||
}
|
if (acc > SHAKE_THRESHOLD && now - lastShake.current > SHAKE_COOLDOWN) {
|
||||||
});
|
lastShake.current = now;
|
||||||
return () => sub.remove();
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||||
|
onShake();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// expo-sensors not linked — shake disabled, no crash
|
||||||
|
}
|
||||||
|
return () => { try { sub?.remove(); } catch {} };
|
||||||
}, [onShake]);
|
}, [onShake]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
main.yml
7
main.yml
|
|
@ -21,7 +21,12 @@ jobs:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- 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
|
- name: Install CocoaPods
|
||||||
run: sudo gem install cocoapods --no-document
|
run: sudo gem install cocoapods --no-document
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue