Add files via upload
This commit is contained in:
parent
9fb037a648
commit
2a8ba7eeba
14 changed files with 478 additions and 387 deletions
|
|
@ -5,6 +5,7 @@ import { BlurView } from 'expo-blur';
|
|||
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
|
||||
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
|
||||
import { useAuth, usePlayer, useLikes } from '../../store';
|
||||
import { useAudio } from '../../hooks/useAudio';
|
||||
import MiniPlayer from '../../components/MiniPlayer';
|
||||
import Player from '../../components/Player';
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ export default function TabsLayout() {
|
|||
const { user, ready } = useAuth();
|
||||
const { expanded } = usePlayer();
|
||||
const { loadLikes } = useLikes();
|
||||
useAudio(); // single audio controller for the whole app
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !user) router.replace('/auth');
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { useEffect, useState, useCallback } from 'react';
|
|||
import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl } from 'react-native';
|
||||
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Image } from 'expo-image';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||
import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
|
||||
import { usePlayer, useLikes } from '../../store';
|
||||
import CoverImage from '../../components/CoverImage';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const CARD_W = (width - Spacing.md * 3) / 2;
|
||||
|
|
@ -47,27 +47,30 @@ export default function HomeScreen() {
|
|||
refreshControl={<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.text2} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<Animated.View entering={FadeIn.duration(400)} style={styles.pageHeader}>
|
||||
<Text style={styles.pageTitle}>NodeShift Music</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Now playing strip (if track exists) */}
|
||||
<NowPlayingStrip />
|
||||
|
||||
{/* Albums */}
|
||||
{albums.length > 0 && (
|
||||
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
||||
<Text style={styles.secTitle}>Альбомы</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hRow}>
|
||||
{albums.map((a, i) => (
|
||||
<AlbumCard key={a.id} album={a} delay={i * 50} />
|
||||
<AlbumCard key={a.id} album={a} delay={i * 50} onPress={() => {
|
||||
const albumTracks = tracks.filter(t => t.album === a.title);
|
||||
if (albumTracks.length > 0) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setQueue(albumTracks, 0);
|
||||
usePlayer.getState().setExpanded(true);
|
||||
}
|
||||
}} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Tracks */}
|
||||
{tracks.length > 0 && (
|
||||
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
|
||||
<Text style={styles.secTitle}>Треки</Text>
|
||||
|
|
@ -92,15 +95,21 @@ function NowPlayingStrip() {
|
|||
const track = queue[index];
|
||||
if (!track) return null;
|
||||
const cover = coverUrl(track.cover_url);
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.duration(300)} style={styles.npStrip}>
|
||||
{cover && <Image source={{ uri: cover }} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={40} />}
|
||||
{/* Blurred cover background */}
|
||||
<CoverImage
|
||||
uri={cover}
|
||||
title={track.title}
|
||||
size={160}
|
||||
radius={0}
|
||||
blurRadius={40}
|
||||
style={StyleSheet.absoluteFill as any}
|
||||
/>
|
||||
<View style={[StyleSheet.absoluteFill, styles.npDim]} />
|
||||
<Pressable style={styles.npContent} onPress={() => setExpanded(true)}>
|
||||
{cover
|
||||
? <Image source={{ uri: cover }} style={styles.npCover} contentFit="cover" />
|
||||
: <View style={[styles.npCover, { backgroundColor: Colors.elevated }]} />
|
||||
}
|
||||
<CoverImage uri={cover} title={track.title} size={52} radius={Radius.sm} />
|
||||
<View style={styles.npInfo}>
|
||||
<Text style={styles.npLabel}>Сейчас играет</Text>
|
||||
<Text style={styles.npTitle} numberOfLines={1}>{track.title}</Text>
|
||||
|
|
@ -108,7 +117,7 @@ function NowPlayingStrip() {
|
|||
</View>
|
||||
<View style={styles.npDot}>
|
||||
{playing
|
||||
? <View style={styles.npEq}>{[1,2,3].map(i => <Animated.View key={i} style={[styles.npBar, { height: 4 + i * 4 }]} />)}</View>
|
||||
? <View style={styles.npEq}>{[1, 2, 3].map(i => <Animated.View key={i} style={[styles.npBar, { height: 4 + i * 4 }]} />)}</View>
|
||||
: <Svg width={16} height={16} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>
|
||||
}
|
||||
</View>
|
||||
|
|
@ -117,25 +126,23 @@ function NowPlayingStrip() {
|
|||
);
|
||||
}
|
||||
|
||||
function AlbumCard({ album, delay }: { album: Album; delay: number }) {
|
||||
function AlbumCard({ album, delay, onPress }: { album: Album; delay: number; onPress: () => void }) {
|
||||
const scale = useSharedValue(1);
|
||||
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||
const cover = coverUrl(album.cover_url);
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(delay)} style={style}>
|
||||
<Pressable
|
||||
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
|
||||
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||
onPress={onPress}
|
||||
style={styles.albumCard}
|
||||
>
|
||||
<Image
|
||||
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||
style={styles.albumCover}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
/>
|
||||
<CoverImage uri={cover} title={album.title} size={CARD_W} radius={Radius.md} style={{ marginBottom: Spacing.sm }} />
|
||||
<Text style={styles.albumTitle} numberOfLines={1}>{album.title}</Text>
|
||||
<Text style={styles.albumArtist} numberOfLines={1}>{album.artist}</Text>
|
||||
<Text style={styles.albumCount}>{album.track_count} треков</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
@ -151,12 +158,7 @@ function TrackRow({ track, index, liked, onPress, onLike }: {
|
|||
return (
|
||||
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
|
||||
<Text style={styles.trackNum}>{index + 1}</Text>
|
||||
<Image
|
||||
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||
style={styles.trackCover}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
<CoverImage uri={cover} title={track.title} size={44} radius={Radius.sm} />
|
||||
<View style={styles.trackInfo}>
|
||||
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
|
||||
|
|
@ -177,35 +179,33 @@ function fmt(s: number) {
|
|||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1, backgroundColor: Colors.bg },
|
||||
content: { paddingHorizontal: Spacing.md },
|
||||
pageHeader: { marginBottom: Spacing.md },
|
||||
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
|
||||
section: { marginBottom: Spacing.xl },
|
||||
secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
|
||||
hRow: { gap: Spacing.md, paddingRight: Spacing.md },
|
||||
albumCard: { width: CARD_W },
|
||||
albumCover: { width: CARD_W, height: CARD_W, borderRadius: Radius.md, backgroundColor: Colors.elevated, marginBottom: Spacing.sm },
|
||||
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
||||
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
||||
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: Spacing.sm },
|
||||
trackRowActive: { backgroundColor: Colors.surface },
|
||||
trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||
trackCover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||
trackInfo: { flex: 1 },
|
||||
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||
trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||
trackDur: { color: Colors.text3, fontSize: Font.xs },
|
||||
scroll: { flex: 1, backgroundColor: Colors.bg },
|
||||
content: { paddingHorizontal: Spacing.md },
|
||||
pageHeader: { marginBottom: Spacing.md },
|
||||
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
|
||||
section: { marginBottom: Spacing.xl },
|
||||
secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
|
||||
hRow: { gap: Spacing.md, paddingRight: Spacing.md },
|
||||
albumCard: { width: CARD_W },
|
||||
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
||||
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
||||
albumCount: { color: Colors.text3, fontSize: Font.xs, marginTop: 2 },
|
||||
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: Spacing.sm },
|
||||
trackRowActive: { backgroundColor: Colors.surface },
|
||||
trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||
trackInfo: { flex: 1 },
|
||||
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||
trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||
trackDur: { color: Colors.text3, fontSize: Font.xs },
|
||||
// Now playing strip
|
||||
npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 },
|
||||
npDim: { backgroundColor: 'rgba(0,0,0,0.55)' },
|
||||
npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
||||
npCover: { width: 52, height: 52, borderRadius: Radius.sm },
|
||||
npInfo: { flex: 1 },
|
||||
npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
|
||||
npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||
npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 },
|
||||
npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 },
|
||||
npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 },
|
||||
npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 },
|
||||
npDim: { backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
||||
npInfo: { flex: 1 },
|
||||
npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
|
||||
npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||
npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 },
|
||||
npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 },
|
||||
npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 },
|
||||
});
|
||||
|
|
|
|||
28
app/auth.tsx
28
app/auth.tsx
|
|
@ -14,6 +14,7 @@ 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();
|
||||
|
||||
|
|
@ -49,6 +50,7 @@ export default function AuthScreen() {
|
|||
}
|
||||
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) {
|
||||
|
|
@ -60,13 +62,13 @@ export default function AuthScreen() {
|
|||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Blob color="#1db954" size={420} left={-80} top={-100} delay={0} />
|
||||
<Blob color="#6c63ff" size={320} left={width - 200} top={height * 0.55} delay={2000} />
|
||||
<Blob color="#1db954" size={240} left={width * 0.3} top={height - 100} delay={4000} />
|
||||
<Blob color={Colors.accent} size={420} left={-80} top={-100} delay={0} />
|
||||
<Blob color="#6c63ff" size={320} left={width - 200} top={height * 0.55} delay={2000} />
|
||||
<Blob color={Colors.accent} size={240} left={width * 0.3} top={height - 100} delay={4000} />
|
||||
|
||||
<Animated.View style={styles.card} entering={FadeIn.duration(600)}>
|
||||
<Animated.View entering={SlideInDown.delay(100).springify()} style={styles.logoWrap}>
|
||||
<LinearGradient colors={['#1db954', '#168f3f']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.logoIcon}>
|
||||
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.logoIcon}>
|
||||
<Svg width={38} height={38} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M9 18V5l12-2v13" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||
<Circle cx={6} cy={18} r={3} fill="#fff" />
|
||||
|
|
@ -86,14 +88,14 @@ export default function AuthScreen() {
|
|||
onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }}
|
||||
onPress={handleLogin}
|
||||
>
|
||||
<LinearGradient colors={['#1db954', '#17a349']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.btn}>
|
||||
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.btn}>
|
||||
{loading
|
||||
? <ActivityIndicator color="#000" size="small" />
|
||||
? <ActivityIndicator color="#fff" size="small" />
|
||||
: (
|
||||
<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="#000" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="8 17 12 13 16 17" stroke="#000" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Line x1="12" y1="13" x2="12" y2="21" stroke="#000" strokeWidth={2} strokeLinecap="round" />
|
||||
<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" />
|
||||
<Polyline points="8 17 12 13 16 17" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Line x1="12" y1="13" x2="12" y2="21" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
)}
|
||||
<Text style={styles.btnText}>
|
||||
|
|
@ -149,15 +151,15 @@ function Blob({ color, size, left, top, delay }: {
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
||||
blob: { position: 'absolute', opacity: 0.15 },
|
||||
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: '#1db954', shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 },
|
||||
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: '#1db954', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 },
|
||||
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: '#000', fontSize: Font.md, fontWeight: '700' },
|
||||
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 },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue