Add files via upload
This commit is contained in:
parent
5f4e59b56b
commit
184247a272
4 changed files with 225 additions and 287 deletions
|
|
@ -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={
|
||||
<View style={s.waveHeader}>
|
||||
{/* Animated wave */}
|
||||
<Animated.View entering={FadeInDown.duration(400)} style={s.waveHeader}>
|
||||
<AnimatedGradient
|
||||
colors={['rgba(67, 97, 238, 0.2)', 'rgba(123, 47, 247, 0.05)', 'transparent']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={s.waveViz}>
|
||||
<WaveAnimation active={!waveLoading} barCount={50} height={64} />
|
||||
<WaveAnimation active={!waveLoading} barCount={60} height={80} />
|
||||
</View>
|
||||
<View style={s.waveGradientOverlay} pointerEvents="none" />
|
||||
<Text style={s.waveTitle}>Моя волна</Text>
|
||||
<Text style={s.waveSub}>
|
||||
{waveLoading ? 'DeepSeek подбирает треки...' : `${wave.length} треков на основе ваших лайков`}
|
||||
{waveLoading ? 'DeepSeek подбирает идеальные треки...' : `${wave.length} треков на основе вашего вкуса`}
|
||||
</Text>
|
||||
{wave.length > 0 && (
|
||||
<Pressable
|
||||
style={s.playAllBtn}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
playTrack(wave, 0);
|
||||
}}
|
||||
>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill={Colors.bg}>
|
||||
<Path d="M8 5v14l11-7z" />
|
||||
</Svg>
|
||||
<Text style={s.playAllText}>Слушать волну</Text>
|
||||
</Pressable>
|
||||
<PulsePlayButton onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
playTrack(wave, 0);
|
||||
}} />
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
}
|
||||
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 (
|
||||
<Animated.View style={st}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: Colors.accent, borderRadius: 30, transform: [{ scale: 1.1 }] }, gl]} />
|
||||
<Pressable
|
||||
onPressIn={() => { scale.value = withSpring(0.92, { damping: 15 }); }}
|
||||
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||
onPress={onPress}
|
||||
style={s.playAllBtn}
|
||||
>
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="#fff">
|
||||
<Path d="M8 5v14l11-7z" />
|
||||
</Svg>
|
||||
<Text style={s.playAllText}>Слушать поток</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
|
|
|
|||
357
app/auth.tsx
357
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<ScrollView>(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 (
|
||||
<ScrollView
|
||||
ref={ref}
|
||||
horizontal
|
||||
scrollEnabled={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ flexGrow: 0 }}
|
||||
>
|
||||
{doubled.map((c, i) => (
|
||||
<LinearGradient key={i} colors={c} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={card} />
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={s.root}>
|
||||
{/* Dark background */}
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: '#07071a' }]} />
|
||||
<LinearGradient
|
||||
colors={['#0d0d25', '#07071a', '#07071a']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Orbs */}
|
||||
<Animated.View style={[s.orb1, orb1Style]}>
|
||||
<LinearGradient colors={['#4361ee', '#7b2ff7']} style={s.orbGrad} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[s.orb2, orb2Style]}>
|
||||
<LinearGradient colors={['#e040fb', '#7b2ff7']} style={s.orbGrad} />
|
||||
{/* Sleek dark background */}
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: '#050505' }]} />
|
||||
|
||||
{/* Modern gradient accent for nodeshiftmusic style */}
|
||||
<Animated.View entering={FadeIn.duration(1000)} style={StyleSheet.absoluteFill}>
|
||||
<LinearGradient
|
||||
colors={['rgba(67, 97, 238, 0.1)', 'rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Scrolling cards — top half */}
|
||||
<View style={s.carousel}>
|
||||
<CardRow reverse={false} speed={16} />
|
||||
<CardRow reverse={true} speed={20} />
|
||||
<CardRow reverse={false} speed={13} />
|
||||
</View>
|
||||
|
||||
{/* Gradient covers carousel bottom half */}
|
||||
<LinearGradient
|
||||
colors={['transparent', '#07071a']}
|
||||
style={s.fadeBottom}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['#07071a', 'transparent']}
|
||||
style={s.fadeTop}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<View style={s.content}>
|
||||
{/* Logo icon */}
|
||||
<Animated.View entering={FadeIn.delay(200).duration(600)} style={s.logoRow}>
|
||||
<View style={s.logoShadow}>
|
||||
<Animated.View style={glowStyle}>
|
||||
<LinearGradient
|
||||
colors={['#4361ee', '#7b2ff7', '#e040fb']}
|
||||
start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }}
|
||||
style={s.logoBox}
|
||||
>
|
||||
<Svg width={32} height={32} viewBox="0 0 32 32" fill="none">
|
||||
<Path
|
||||
d="M3 16 Q7 7, 11 16 Q15 25, 19 16 Q23 7, 29 16"
|
||||
stroke="#fff" strokeWidth={2.5} strokeLinecap="round" fill="none"
|
||||
/>
|
||||
<Circle cx={16} cy={16} r={2.5} fill="rgba(255,255,255,0.55)" />
|
||||
</Svg>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</View>
|
||||
<Animated.View entering={FadeInDown.delay(200).springify()} style={s.logoContainer}>
|
||||
<Image
|
||||
source={require('../assets/icon.png')}
|
||||
style={s.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Brand name */}
|
||||
<Animated.Text entering={FadeInDown.delay(300).springify()} style={s.brand}>
|
||||
Burmalda
|
||||
NodeShift
|
||||
</Animated.Text>
|
||||
|
||||
<Animated.Text entering={FadeInDown.delay(400).springify()} style={s.subtitle}>
|
||||
Музыкальная экосистема
|
||||
</Animated.Text>
|
||||
|
||||
{/* Tagline */}
|
||||
<Animated.Text entering={FadeInDown.delay(450).duration(600)} style={s.tagline}>
|
||||
«Если я говорю сукин сын и сын шлюхи — это не одно и то же»
|
||||
</Animated.Text>
|
||||
|
||||
{/* Feature list */}
|
||||
<Animated.View entering={FadeInUp.delay(600).springify()} style={s.features}>
|
||||
<FeatureItem color="#4361ee" text="Умные рекомендации DeepSeek" />
|
||||
<FeatureItem color="#7b2ff7" text="Моя волна — персональный микс" />
|
||||
<FeatureItem color="#00b4d8" text="Оффлайн — треки без интернета" />
|
||||
</Animated.View>
|
||||
|
||||
{/* Button */}
|
||||
<Animated.View entering={FadeInUp.delay(750).springify()}>
|
||||
<Animated.View style={btnStyle}>
|
||||
<Pressable
|
||||
onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 12 }); }}
|
||||
onPressOut={() => { btnScale.value = withSpring(1, { damping: 12 }); }}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
<Animated.View entering={FadeInUp.delay(500).springify()} style={s.btnWrapper}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
s.btn,
|
||||
pressed && s.btnPressed
|
||||
]}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#4361ee', '#3a0ca3']}
|
||||
start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }}
|
||||
style={s.btnGradient}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#4361ee', '#7b2ff7', '#e040fb']}
|
||||
start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }}
|
||||
style={s.btn}
|
||||
>
|
||||
{loading
|
||||
? <ActivityIndicator color="#fff" size="small" />
|
||||
: (
|
||||
<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"
|
||||
stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
<Text style={s.btnText}>
|
||||
{loading ? 'Входим…' : 'Войти через NodeShift'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={s.btnText}>Войти</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
{!!error && (
|
||||
<Animated.Text entering={FadeIn} style={s.error}>{error}</Animated.Text>
|
||||
)}
|
||||
|
||||
<Animated.Text entering={FadeIn.delay(900)} style={s.terms}>
|
||||
<Animated.Text entering={FadeIn.delay(700)} style={s.terms}>
|
||||
Продолжая, вы соглашаетесь с условиями NodeShift
|
||||
</Animated.Text>
|
||||
</View>
|
||||
|
|
@ -258,42 +111,80 @@ export default function AuthScreen() {
|
|||
);
|
||||
}
|
||||
|
||||
function FeatureItem({ color, text }: { color: string; text: string }) {
|
||||
return (
|
||||
<View style={s.featureRow}>
|
||||
<View style={[s.dot, { backgroundColor: color }]} />
|
||||
<Text style={s.featureText}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<View style={s.bottomRow}>
|
||||
<Pressable
|
||||
style={[s.lyrBtn, !!(lyricsMode || lrcLines.length > 0 || plainLyrics) && s.lyrBtnActive]}
|
||||
onPress={loadLyrics}
|
||||
>
|
||||
<Svg width={15} height={15} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M3 8h18M3 12h18M3 16h12" stroke={lyricsMode ? Colors.accent : 'rgba(255,255,255,0.5)'} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[s.lyrBtnText, lyricsMode && { color: Colors.accent }]}>
|
||||
{lyricsLoading ? 'Загрузка...' : 'Текст песни'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<IconBtn scale={0.92} onPress={loadLyrics}>
|
||||
<View style={[s.lyrBtn, !!(lyricsMode || lrcLines.length > 0 || plainLyrics) && s.lyrBtnActive]}>
|
||||
<Svg width={15} height={15} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M3 8h18M3 12h18M3 16h12" stroke={lyricsMode ? Colors.accent : 'rgba(255,255,255,0.5)'} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[s.lyrBtnText, lyricsMode && { color: Colors.accent }]}>
|
||||
{lyricsLoading ? 'Загрузка...' : 'Текст'}
|
||||
</Text>
|
||||
</View>
|
||||
</IconBtn>
|
||||
|
||||
<IconBtn scale={0.92} onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
Alert.alert('VST Эффекты', 'Плагины для обработки звука будут добавлены в следующем обновлении.');
|
||||
}}>
|
||||
<View style={s.lyrBtn}>
|
||||
<Svg width={15} height={15} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M4 6h16M4 12h16M4 18h16M8 3v6M16 9v6M12 15v6" stroke="rgba(255,255,255,0.5)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
<Text style={s.lyrBtnText}>VST / Звук</Text>
|
||||
</View>
|
||||
</IconBtn>
|
||||
</View>
|
||||
|
||||
</Animated.View>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue