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