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