Add files via upload

This commit is contained in:
Savsis 2026-05-05 18:17:09 +05:00 • committed by GitHub
parent d612a65f81
commit 5748d2d13c
31 changed files with 3197 additions and 2492 deletions

117
app.json
View file

@ -1,58 +1,59 @@
{ {
"expo": { "expo": {
"name": "Burmalda Music", "name": "Burmalda Music",
"slug": "burmalda-music", "slug": "burmalda-music",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"splash": { "splash": {
"image": "./assets/splash.png", "image": "./assets/splash.png",
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#0a0a0a" "backgroundColor": "#0a0a0a"
}, },
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"supportsTablet": false, "supportsTablet": false,
"bundleIdentifier": "space.nodeshift.burmalda", "bundleIdentifier": "space.nodeshift.burmalda",
"buildNumber": "1", "buildNumber": "1",
"infoPlist": { "infoPlist": {
"UIBackgroundModes": ["audio"], "UIBackgroundModes": ["audio"],
"NSMicrophoneUsageDescription": "Не используется", "NSMicrophoneUsageDescription": "Не используется",
"CFBundleURLTypes": [ "NSFileProtectionComplete": "NSFileProtectionCompleteUnlessOpen",
{ "CFBundleURLTypes": [
"CFBundleURLSchemes": ["burmalda"] {
} "CFBundleURLSchemes": ["burmalda"]
] }
} ]
}, }
"scheme": "burmalda", },
"plugins": [ "scheme": "burmalda",
"expo-router", "plugins": [
"react-native-track-player", "expo-router",
[ "react-native-track-player",
"expo-updates", [
{ "expo-updates",
"username": "nodeshift" {
} "username": "nodeshift"
] }
], ]
"updates": { ],
"enabled": true, "updates": {
"checkAutomatically": "ON_LOAD", "enabled": true,
"url": "https://nodeshift.space/api/ios/updates" "checkAutomatically": "ON_LOAD",
}, "url": "https://nodeshift.space/api/ios/updates"
"runtimeVersion": { },
"policy": "appVersion" "runtimeVersion": {
}, "policy": "appVersion"
"extra": { },
"apiBase": "https://music.nodeshift.space", "extra": {
"cloudClientId": "nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U", "apiBase": "https://music.nodeshift.space",
"eas": {} "cloudClientId": "nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U",
"eas": {}
},
"experiments": { },
"typedRoutes": true "experiments": {
} "typedRoutes": true
} }
} }
}

View file

@ -1,111 +1,111 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { View, StyleSheet, ActivityIndicator } from 'react-native'; import { View, StyleSheet, ActivityIndicator } from 'react-native';
import { Tabs, router } from 'expo-router'; import { Tabs, router } from 'expo-router';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg'; import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme'; import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
import { useAuth, usePlayer, useLikes } from '../../store'; import { useAuth, usePlayer, useLikes } from '../../store';
import { useAudio } from '../../hooks/useAudio'; import { useAudio } from '../../hooks/useAudio';
import MiniPlayer from '../../components/MiniPlayer'; import MiniPlayer from '../../components/MiniPlayer';
import Player from '../../components/Player'; import Player from '../../components/Player';
export default function TabsLayout() { export default function TabsLayout() {
const { user, ready } = useAuth(); const { user, ready } = useAuth();
const { expanded } = usePlayer(); const { expanded } = usePlayer();
const { loadLikes } = useLikes(); const { loadLikes } = useLikes();
useAudio(); // single audio controller for the whole app useAudio(); // single audio controller for the whole app
useEffect(() => { useEffect(() => {
if (ready && !user) router.replace('/auth'); if (ready && !user) router.replace('/auth');
}, [user, ready]); }, [user, ready]);
useEffect(() => { useEffect(() => {
if (user) loadLikes(); if (user) loadLikes();
}, [user]); }, [user]);
if (!ready) return ( if (!ready) return (
<View style={{ flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center' }}> <View style={{ flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color={Colors.accent} /> <ActivityIndicator color={Colors.accent} />
</View> </View>
); );
if (!user) return null; if (!user) return null;
return ( return (
<View style={{ flex: 1, backgroundColor: Colors.bg }}> <View style={{ flex: 1, backgroundColor: Colors.bg }}>
<Tabs <Tabs
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
tabBarStyle: styles.tabBar, tabBarStyle: styles.tabBar,
tabBarBackground: () => ( tabBarBackground: () => (
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} /> <BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
), ),
tabBarActiveTintColor: Colors.text, tabBarActiveTintColor: Colors.text,
tabBarInactiveTintColor: Colors.text3, tabBarInactiveTintColor: Colors.text3,
tabBarLabelStyle: { fontSize: 9, fontWeight: '500', letterSpacing: 0.3, marginBottom: 4 }, tabBarLabelStyle: { fontSize: 9, fontWeight: '500', letterSpacing: 0.3, marginBottom: 4 },
tabBarItemStyle: { paddingTop: 6 }, tabBarItemStyle: { paddingTop: 6 },
}} }}
> >
<Tabs.Screen name="index" options={{ title: 'Главная', tabBarIcon: ({ color }) => <HomeIcon color={color} /> }} /> <Tabs.Screen name="index" options={{ title: 'Главная', tabBarIcon: ({ color }) => <HomeIcon color={color} /> }} />
<Tabs.Screen name="library" options={{ title: 'Библиотека', tabBarIcon: ({ color }) => <LibIcon color={color} /> }} /> <Tabs.Screen name="library" options={{ title: 'Библиотека', tabBarIcon: ({ color }) => <LibIcon color={color} /> }} />
<Tabs.Screen name="search" options={{ title: 'Поиск', tabBarIcon: ({ color }) => <SearchIcon color={color} /> }} /> <Tabs.Screen name="search" options={{ title: 'Поиск', tabBarIcon: ({ color }) => <SearchIcon color={color} /> }} />
<Tabs.Screen name="liked" options={{ title: 'Понравилось', tabBarIcon: ({ color }) => <HeartIcon color={color} /> }} /> <Tabs.Screen name="liked" options={{ title: 'Понравилось', tabBarIcon: ({ color }) => <HeartIcon color={color} /> }} />
<Tabs.Screen name="profile" options={{ title: 'Профиль', tabBarIcon: ({ color }) => <ProfileIcon color={color} /> }} /> <Tabs.Screen name="profile" options={{ title: 'Профиль', tabBarIcon: ({ color }) => <ProfileIcon color={color} /> }} />
</Tabs> </Tabs>
<MiniPlayer /> <MiniPlayer />
{expanded && <Player />} {expanded && <Player />}
</View> </View>
); );
} }
function HomeIcon({ color }: { color: string }) { function HomeIcon({ color }: { color: string }) {
return ( return (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none"> <Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
<Polyline points="9,22 9,12 15,12 15,22" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Polyline points="9,22 9,12 15,12 15,22" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg> </Svg>
); );
} }
function LibIcon({ color }: { color: string }) { function LibIcon({ color }: { color: string }) {
return ( return (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none"> <Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M9 18V5l12-2v13" stroke={color} strokeWidth={1.8} strokeLinecap="round" /> <Path d="M9 18V5l12-2v13" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
<Circle cx={6} cy={18} r={3} stroke={color} strokeWidth={1.8} /> <Circle cx={6} cy={18} r={3} stroke={color} strokeWidth={1.8} />
<Circle cx={18} cy={16} r={3} stroke={color} strokeWidth={1.8} /> <Circle cx={18} cy={16} r={3} stroke={color} strokeWidth={1.8} />
</Svg> </Svg>
); );
} }
function SearchIcon({ color }: { color: string }) { function SearchIcon({ color }: { color: string }) {
return ( return (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none"> <Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Circle cx={11} cy={11} r={8} stroke={color} strokeWidth={1.8} /> <Circle cx={11} cy={11} r={8} stroke={color} strokeWidth={1.8} />
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={color} strokeWidth={1.8} strokeLinecap="round" /> <Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
); );
} }
function HeartIcon({ color }: { color: string }) { function HeartIcon({ color }: { color: string }) {
return ( return (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none"> <Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg> </Svg>
); );
} }
function ProfileIcon({ color }: { color: string }) { function ProfileIcon({ color }: { color: string }) {
return ( return (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none"> <Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" stroke={color} strokeWidth={1.8} strokeLinecap="round" /> <Path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" stroke={color} strokeWidth={1.8} strokeLinecap="round" />
<Circle cx={12} cy={7} r={4} stroke={color} strokeWidth={1.8} /> <Circle cx={12} cy={7} r={4} stroke={color} strokeWidth={1.8} />
</Svg> </Svg>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
tabBar: { tabBar: {
position: 'absolute', position: 'absolute',
borderTopWidth: StyleSheet.hairlineWidth, borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(255,255,255,0.08)', borderTopColor: 'rgba(255,255,255,0.08)',
backgroundColor: 'transparent', backgroundColor: 'transparent',
elevation: 0, elevation: 0,
height: TAB_BAR_HEIGHT, height: TAB_BAR_HEIGHT,
}, },
}); });

View file

@ -1,211 +1,290 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl } from 'react-native'; import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl, ActivityIndicator } from 'react-native';
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg'; import { useFocusEffect } from 'expo-router';
import * as Haptics from 'expo-haptics'; import * as Network from 'expo-network';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; import Svg, { Path } from 'react-native-svg';
import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api'; import * as Haptics from 'expo-haptics';
import { usePlayer, useLikes } from '../../store'; import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
import CoverImage from '../../components/CoverImage'; import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
import { downloadTrack, deleteTrack, isTrackDownloaded, DownloadProgress, cacheTracksAndAlbums, getCachedTracks, getCachedAlbums, initOfflineStorage } from '../../services/offline';
const { width } = Dimensions.get('window'); import { usePlayer, useLikes, useOffline } from '../../store';
const CARD_W = (width - Spacing.md * 3) / 2; import CoverImage from '../../components/CoverImage';
import DownloadButton from '../../components/DownloadButton';
export default function HomeScreen() {
const [tracks, setTracks] = useState<Track[]>([]); const { width } = Dimensions.get('window');
const [albums, setAlbums] = useState<Album[]>([]); const CARD_W = (width - Spacing.md * 3) / 2;
const [refresh, setRefresh] = useState(false);
const insets = useSafeAreaInsets(); export default function HomeScreen() {
const { setQueue } = usePlayer(); const [tracks, setTracks] = useState<Track[]>([]);
const { toggleLike, isLiked } = useLikes(); const [albums, setAlbums] = useState<Album[]>([]);
const [refresh, setRefresh] = useState(false);
const load = useCallback(async () => { const [loading, setLoading] = useState(true);
const [t, a] = await Promise.all([getTracks(), getAlbums()]); const insets = useSafeAreaInsets();
setTracks(t); const { setQueue } = usePlayer();
setAlbums(a); const { toggleLike, isLiked } = useLikes();
}, []); const { setOnline, loadDownloadedTracks } = useOffline();
useEffect(() => { load(); }, []); const load = useCallback(async () => {
// Check network
const onRefresh = async () => { const isOnline = await Network.getNetworkStateAsync();
setRefresh(true); setOnline(isOnline.isConnected);
await load();
setRefresh(false); if (isOnline.isConnected) {
}; try {
const [t, a] = await Promise.all([getTracks(), getAlbums()]);
const playTrack = (track: Track, list: Track[], idx: number) => { if (t.length > 0) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setTracks(t);
setQueue(list, idx); setAlbums(a);
usePlayer.getState().setExpanded(true); await cacheTracksAndAlbums(t, a);
}; setLoading(false);
return;
return ( }
<ScrollView } catch (err) {
style={styles.scroll} console.warn('Failed to fetch online:', err);
contentContainerStyle={[styles.content, { paddingTop: insets.top + 14, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }]} }
refreshControl={<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.text2} />} }
showsVerticalScrollIndicator={false}
> // Fallback to cached data
<Animated.View entering={FadeIn.duration(400)} style={styles.pageHeader}> const cachedTracks = await getCachedTracks();
<Text style={styles.pageTitle}>NodeShift Music</Text> const cachedAlbums = await getCachedAlbums();
</Animated.View> if (cachedTracks) setTracks(cachedTracks);
if (cachedAlbums) setAlbums(cachedAlbums);
<NowPlayingStrip /> setLoading(false);
}, [setOnline]);
{albums.length > 0 && (
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}> useEffect(() => {
<Text style={styles.secTitle}>Альбомы</Text> initOfflineStorage();
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hRow}> loadDownloadedTracks();
{albums.map((a, i) => ( load();
<AlbumCard key={a.id} album={a} delay={i * 50} onPress={() => { }, []);
const albumTracks = tracks.filter(t => t.album === a.title);
if (albumTracks.length > 0) { useFocusEffect(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); loadDownloadedTracks();
setQueue(albumTracks, 0); });
usePlayer.getState().setExpanded(true);
} const onRefresh = async () => {
}} /> setRefresh(true);
))} await load();
</ScrollView> setRefresh(false);
</Animated.View> };
)}
const playTrack = (track: Track, list: Track[], idx: number) => {
{tracks.length > 0 && ( Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}> setQueue(list, idx);
<Text style={styles.secTitle}>Треки</Text> usePlayer.getState().setExpanded(true);
{tracks.map((t, i) => ( };
<TrackRow
key={t.id} if (loading && tracks.length === 0) {
track={t} return (
index={i} <View style={[styles.scroll, { justifyContent: 'center', alignItems: 'center' }]}>
liked={isLiked(t.id)} <ActivityIndicator size="large" color={Colors.accent} />
onPress={() => playTrack(t, tracks, i)} </View>
onLike={() => toggleLike(t.id)} );
/> }
))}
</Animated.View> return (
)} <ScrollView
</ScrollView> style={styles.scroll}
); contentContainerStyle={[styles.content, { paddingTop: insets.top + 14, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }]}
} refreshControl={<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.text2} />}
showsVerticalScrollIndicator={false}
function NowPlayingStrip() { >
const { queue, index, playing, setExpanded } = usePlayer(); <Animated.View entering={FadeIn.duration(400)} style={styles.pageHeader}>
const track = queue[index]; <Text style={styles.pageTitle}>NodeShift Music</Text>
if (!track) return null; </Animated.View>
const cover = coverUrl(track.cover_url);
<NowPlayingStrip />
return (
<Animated.View entering={FadeInDown.duration(300)} style={styles.npStrip}> {albums.length > 0 && (
{/* Blurred cover background */} <Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
<CoverImage <Text style={styles.secTitle}>Альбомы</Text>
uri={cover} <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hRow}>
title={track.title} {albums.map((a, i) => (
size={160} <AlbumCard key={a.id} album={a} delay={i * 50} onPress={() => {
radius={0} const albumTracks = tracks.filter(t => t.album === a.title);
blurRadius={40} if (albumTracks.length > 0) {
style={StyleSheet.absoluteFill as any} Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
/> setQueue(albumTracks, 0);
<View style={[StyleSheet.absoluteFill, styles.npDim]} /> usePlayer.getState().setExpanded(true);
<Pressable style={styles.npContent} onPress={() => setExpanded(true)}> }
<CoverImage uri={cover} title={track.title} size={52} radius={Radius.sm} /> }} />
<View style={styles.npInfo}> ))}
<Text style={styles.npLabel}>Сейчас играет</Text> </ScrollView>
<Text style={styles.npTitle} numberOfLines={1}>{track.title}</Text> </Animated.View>
<Text style={styles.npArtist} numberOfLines={1}>{track.artist}</Text> )}
</View>
<View style={styles.npDot}> {tracks.length > 0 && (
{playing <Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
? <View style={styles.npEq}>{[1, 2, 3].map(i => <Animated.View key={i} style={[styles.npBar, { height: 4 + i * 4 }]} />)}</View> <Text style={styles.secTitle}>Треки</Text>
: <Svg width={16} height={16} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg> {tracks.map((t, i) => (
} <TrackRow
</View> key={t.id}
</Pressable> track={t}
</Animated.View> index={i}
); liked={isLiked(t.id)}
} onPress={() => playTrack(t, tracks, i)}
onLike={() => toggleLike(t.id)}
function AlbumCard({ album, delay, onPress }: { album: Album; delay: number; onPress: () => void }) { />
const scale = useSharedValue(1); ))}
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); </Animated.View>
const cover = coverUrl(album.cover_url); )}
</ScrollView>
return ( );
<Animated.View entering={FadeInDown.delay(delay)} style={style}> }
<Pressable
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }} function NowPlayingStrip() {
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} const { queue, index, playing, setExpanded } = usePlayer();
onPress={onPress} const track = queue[index];
style={styles.albumCard} if (!track) return null;
> const cover = coverUrl(track.cover_url);
<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> return (
<Text style={styles.albumArtist} numberOfLines={1}>{album.artist}</Text> <Animated.View entering={FadeInDown.duration(300)} style={styles.npStrip}>
<Text style={styles.albumCount}>{album.track_count} треков</Text> {/* Blurred cover background */}
</Pressable> <CoverImage
</Animated.View> uri={cover}
); title={track.title}
} size={160}
radius={0}
function TrackRow({ track, index, liked, onPress, onLike }: { blurRadius={40}
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void; style={StyleSheet.absoluteFill as any}
}) { />
const { queue, index: qi } = usePlayer(); <View style={[StyleSheet.absoluteFill, styles.npDim]} />
const active = queue[qi]?.id === track.id; <Pressable style={styles.npContent} onPress={() => setExpanded(true)}>
const cover = coverUrl(track.cover_url); <CoverImage uri={cover} title={track.title} size={52} radius={Radius.sm} />
<View style={styles.npInfo}>
return ( <Text style={styles.npLabel}>Сейчас играет</Text>
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}> <Text style={styles.npTitle} numberOfLines={1}>{track.title}</Text>
<Text style={styles.trackNum}>{index + 1}</Text> <Text style={styles.npArtist} numberOfLines={1}>{track.artist}</Text>
<CoverImage uri={cover} title={track.title} size={44} radius={Radius.sm} /> </View>
<View style={styles.trackInfo}> <View style={styles.npDot}>
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text> {playing
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text> ? <View style={styles.npEq}>{[1, 2, 3].map(i => <Animated.View key={i} style={[styles.npBar, { height: 4 + i * 4 }]} />)}</View>
</View> : <Svg width={16} height={16} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>
<Pressable onPress={onLike} hitSlop={12}> }
<Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}> </View>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> </Pressable>
</Svg> </Animated.View>
</Pressable> );
<Text style={styles.trackDur}>{fmt(track.duration)}</Text> }
</Pressable>
); function AlbumCard({ album, delay, onPress }: { album: Album; delay: number; onPress: () => void }) {
} const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
function fmt(s: number) { const cover = coverUrl(album.cover_url);
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? '0' : ''}${sec}`; return (
} <Animated.View entering={FadeInDown.delay(delay)} style={style}>
<Pressable
const styles = StyleSheet.create({ onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
scroll: { flex: 1, backgroundColor: Colors.bg }, onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
content: { paddingHorizontal: Spacing.md }, onPress={onPress}
pageHeader: { marginBottom: Spacing.md }, style={styles.albumCard}
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 }, >
section: { marginBottom: Spacing.xl }, <CoverImage uri={cover} title={album.title} size={CARD_W} radius={Radius.md} style={{ marginBottom: Spacing.sm }} />
secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md }, <Text style={styles.albumTitle} numberOfLines={1}>{album.title}</Text>
hRow: { gap: Spacing.md, paddingRight: Spacing.md }, <Text style={styles.albumArtist} numberOfLines={1}>{album.artist}</Text>
albumCard: { width: CARD_W }, <Text style={styles.albumCount}>{album.track_count} треков</Text>
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' }, </Pressable>
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 }, </Animated.View>
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' }, function TrackRow({ track, index, liked, onPress, onLike }: {
trackInfo: { flex: 1 }, track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, }) {
trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, const { queue, index: qi } = usePlayer();
trackDur: { color: Colors.text3, fontSize: Font.xs }, const { downloadedTracks, downloading, addDownloadedTrack, removeDownloadedTrack, setDownloading } = useOffline();
// Now playing strip const [isDownloaded, setIsDownloaded] = useState(false);
npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 }, const active = queue[qi]?.id === track.id;
npDim: { backgroundColor: 'rgba(0,0,0,0.5)' }, const cover = coverUrl(track.cover_url);
npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 }, const downloadProgress = downloading.get(track.id) || 0;
npInfo: { flex: 1 },
npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 }, useEffect(() => {
npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, setIsDownloaded(downloadedTracks.has(track.id));
npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 }, }, [downloadedTracks, track.id]);
npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 }, const handleDownload = async () => {
npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 }, if (isDownloaded) {
}); await deleteTrack(track.id);
removeDownloadedTrack(track.id);
setIsDownloaded(false);
} else {
try {
const success = await downloadTrack(track, (prog) => {
setDownloading(track.id, prog.progress);
});
if (success) {
addDownloadedTrack(track.id);
setIsDownloaded(true);
}
} catch (err) {
console.warn('Download failed:', err);
}
}
};
return (
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
<Text style={styles.trackNum}>{index + 1}</Text>
<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>
</View>
<Pressable onPress={onLike} hitSlop={12}>
<Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
</Pressable>
<DownloadButton
trackId={track.id}
isDownloaded={isDownloaded}
downloadProgress={downloadProgress}
onDownload={handleDownload}
onDelete={handleDownload}
size="sm"
/>
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
</Pressable>
);
}
function fmt(s: number) {
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
}
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 },
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: 8 },
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, width: 36, textAlign: 'right' },
// Now playing strip
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 },
});

View file

@ -1,140 +1,140 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, StyleSheet, TextInput, Pressable } from 'react-native'; import { View, Text, FlatList, StyleSheet, TextInput, Pressable } from 'react-native';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import Svg, { Circle, Line, Path } from 'react-native-svg'; import Svg, { Circle, Line, Path } from 'react-native-svg';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
import { getTracks, Track, coverUrl } from '../../services/api'; import { getTracks, Track, coverUrl } from '../../services/api';
import { usePlayer, useLikes } from '../../store'; import { usePlayer, useLikes } from '../../store';
export default function LibraryScreen() { export default function LibraryScreen() {
const [all, setAll] = useState<Track[]>([]); const [all, setAll] = useState<Track[]>([]);
const [filtered, setFiltered] = useState<Track[]>([]); const [filtered, setFiltered] = useState<Track[]>([]);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { setQueue } = usePlayer(); const { setQueue } = usePlayer();
const { toggleLike, isLiked } = useLikes(); const { toggleLike, isLiked } = useLikes();
useEffect(() => { useEffect(() => {
getTracks().then(t => { setAll(t); setFiltered(t); }); getTracks().then(t => { setAll(t); setFiltered(t); });
}, []); }, []);
useEffect(() => { useEffect(() => {
const q = query.toLowerCase(); const q = query.toLowerCase();
setFiltered(q ? all.filter(t => setFiltered(q ? all.filter(t =>
t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q) t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q)
) : all); ) : all);
}, [query, all]); }, [query, all]);
const playTrack = (idx: number) => { const playTrack = (idx: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setQueue(filtered, idx); setQueue(filtered, idx);
usePlayer.getState().setExpanded(true); usePlayer.getState().setExpanded(true);
}; };
return ( return (
<View style={[styles.container, { paddingTop: insets.top }]}> <View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>Библиотека</Text> <Text style={styles.title}>Библиотека</Text>
<Text style={styles.count}>{all.length} треков</Text> <Text style={styles.count}>{all.length} треков</Text>
</View> </View>
{/* Search */} {/* Search */}
<View style={styles.searchWrap}> <View style={styles.searchWrap}>
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none" style={{ marginLeft: 12 }}> <Svg width={14} height={14} viewBox="0 0 24 24" fill="none" style={{ marginLeft: 12 }}>
<Circle cx={11} cy={11} r={8} stroke={Colors.text3} strokeWidth={1.8} /> <Circle cx={11} cy={11} r={8} stroke={Colors.text3} strokeWidth={1.8} />
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" /> <Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
<TextInput <TextInput
style={styles.searchInput} style={styles.searchInput}
placeholder="Поиск треков..." placeholder="Поиск треков..."
placeholderTextColor={Colors.text3} placeholderTextColor={Colors.text3}
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
returnKeyType="search" returnKeyType="search"
autoCorrect={false} autoCorrect={false}
/> />
{!!query && ( {!!query && (
<Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}> <Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}>
<Text style={{ color: Colors.text3, fontSize: 12 }}>✕</Text> <Text style={{ color: Colors.text3, fontSize: 12 }}>✕</Text>
</Pressable> </Pressable>
)} )}
</View> </View>
<FlatList <FlatList
data={filtered} data={filtered}
keyExtractor={t => String(t.id)} keyExtractor={t => String(t.id)}
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<TrackItem <TrackItem
track={item} track={item}
index={index} index={index}
liked={isLiked(item.id)} liked={isLiked(item.id)}
onPress={() => playTrack(index)} onPress={() => playTrack(index)}
onLike={() => toggleLike(item.id)} onLike={() => toggleLike(item.id)}
/> />
)} )}
/> />
</View> </View>
); );
} }
function TrackItem({ track, index, liked, onPress, onLike }: { function TrackItem({ track, index, liked, onPress, onLike }: {
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void; track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
}) { }) {
const { queue, index: qi } = usePlayer(); const { queue, index: qi } = usePlayer();
const active = queue[qi]?.id === track.id; const active = queue[qi]?.id === track.id;
const cover = coverUrl(track.cover_url); const cover = coverUrl(track.cover_url);
return ( return (
<Animated.View entering={FadeInDown.delay(Math.min(index * 25, 300)).duration(300)}> <Animated.View entering={FadeInDown.delay(Math.min(index * 25, 300)).duration(300)}>
<Pressable <Pressable
onPress={onPress} onPress={onPress}
style={[styles.row, active && styles.rowActive]} style={[styles.row, active && styles.rowActive]}
> >
<Text style={styles.num}>{index + 1}</Text> <Text style={styles.num}>{index + 1}</Text>
<Image <Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')} source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={styles.cover} style={styles.cover}
contentFit="cover" contentFit="cover"
transition={200} transition={200}
/> />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text> <Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text> <Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
</View> </View>
<Pressable onPress={onLike} hitSlop={12}> <Pressable onPress={onLike} hitSlop={12}>
<Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}> <Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg> </Svg>
</Pressable> </Pressable>
<Text style={styles.dur}>{fmt(track.duration)}</Text> <Text style={styles.dur}>{fmt(track.duration)}</Text>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
); );
} }
function fmt(s: number) { function fmt(s: number) {
const m = Math.floor(s / 60), sec = Math.floor(s % 60); const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? '0' : ''}${sec}`; return `${m}:${sec < 10 ? '0' : ''}${sec}`;
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.bg }, container: { flex: 1, backgroundColor: Colors.bg },
header: { flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm }, header: { flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm },
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 }, title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 },
count: { color: Colors.text3, fontSize: Font.sm }, count: { color: Colors.text3, fontSize: Font.sm },
searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: 8 }, searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: 8 },
searchInput:{ flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11, paddingRight: Spacing.md }, searchInput:{ flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11, paddingRight: Spacing.md },
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: Spacing.sm, borderRadius: Radius.sm, paddingHorizontal: 4 }, row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: Spacing.sm, borderRadius: Radius.sm, paddingHorizontal: 4 },
rowActive: { backgroundColor: Colors.surface }, rowActive: { backgroundColor: Colors.surface },
num: { width: 22, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, num: { width: 22, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
cover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated }, cover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
dur: { color: Colors.text3, fontSize: Font.xs }, dur: { color: Colors.text3, fontSize: Font.xs },
}); });

View file

@ -1,122 +1,122 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { View, Text, FlatList, StyleSheet, Pressable } from 'react-native'; import { View, Text, FlatList, StyleSheet, Pressable } from 'react-native';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
import { getTracks, Track, coverUrl } from '../../services/api'; import { getTracks, Track, coverUrl } from '../../services/api';
import { usePlayer, useLikes } from '../../store'; import { usePlayer, useLikes } from '../../store';
export default function LikedScreen() { export default function LikedScreen() {
const [all, setAll] = useState<Track[]>([]); const [all, setAll] = useState<Track[]>([]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { setQueue } = usePlayer(); const { setQueue } = usePlayer();
const { likes, toggleLike } = useLikes(); const { likes, toggleLike } = useLikes();
useEffect(() => { useEffect(() => {
getTracks().then(setAll); getTracks().then(setAll);
}, []); }, []);
const liked = all.filter(t => likes.has(t.id)); const liked = all.filter(t => likes.has(t.id));
const playTrack = (track: Track, idx: number) => { const playTrack = (track: Track, idx: number) => {
setQueue(liked, idx); setQueue(liked, idx);
usePlayer.getState().setExpanded(true); usePlayer.getState().setExpanded(true);
}; };
return ( return (
<View style={[styles.container, { paddingTop: insets.top }]}> <View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.header}> <View style={styles.header}>
<Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.pink}> <Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.pink}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={Colors.pink} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={Colors.pink} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg> </Svg>
<Text style={styles.title}>Понравилось</Text> <Text style={styles.title}>Понравилось</Text>
<Text style={styles.count}>{liked.length} треков</Text> <Text style={styles.count}>{liked.length} треков</Text>
</View> </View>
{liked.length === 0 ? ( {liked.length === 0 ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={styles.emptyIcon}>♡</Text> <Text style={styles.emptyIcon}>♡</Text>
<Text style={styles.emptyTitle}>Пусто</Text> <Text style={styles.emptyTitle}>Пусто</Text>
<Text style={styles.emptyText}>Нажмите ♡ у трека, чтобы добавить его сюда</Text> <Text style={styles.emptyText}>Нажмите ♡ у трека, чтобы добавить его сюда</Text>
</View> </View>
) : ( ) : (
<FlatList <FlatList
data={liked} data={liked}
keyExtractor={t => String(t.id)} keyExtractor={t => String(t.id)}
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<LikedRow <LikedRow
track={item} track={item}
index={index} index={index}
onPress={() => playTrack(item, index)} onPress={() => playTrack(item, index)}
onUnlike={() => toggleLike(item.id)} onUnlike={() => toggleLike(item.id)}
/> />
)} )}
/> />
)} )}
</View> </View>
); );
} }
function LikedRow({ track, index, onPress, onUnlike }: { function LikedRow({ track, index, onPress, onUnlike }: {
track: Track; index: number; onPress: () => void; onUnlike: () => void; track: Track; index: number; onPress: () => void; onUnlike: () => void;
}) { }) {
const { queue, index: qi } = usePlayer(); const { queue, index: qi } = usePlayer();
const active = queue[qi]?.id === track.id; const active = queue[qi]?.id === track.id;
const scale = useSharedValue(1); const scale = useSharedValue(1);
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const cover = coverUrl(track.cover_url); const cover = coverUrl(track.cover_url);
return ( return (
<Animated.View entering={FadeInDown.delay(Math.min(index * 40, 400)).duration(300)} style={aStyle}> <Animated.View entering={FadeInDown.delay(Math.min(index * 40, 400)).duration(300)} style={aStyle}>
<Pressable <Pressable
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }} onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
onPress={onPress} onPress={onPress}
style={[styles.row, active && styles.rowActive]} style={[styles.row, active && styles.rowActive]}
> >
<Image <Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')} source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={styles.cover} style={styles.cover}
contentFit="cover" contentFit="cover"
transition={200} transition={200}
/> />
<View style={styles.info}> <View style={styles.info}>
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text> <Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text> <Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
</View> </View>
<Pressable onPress={onUnlike} hitSlop={12} style={styles.likeBtn}> <Pressable onPress={onUnlike} hitSlop={12} style={styles.likeBtn}>
<Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.pink}> <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.pink}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={Colors.pink} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={Colors.pink} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg> </Svg>
</Pressable> </Pressable>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
); );
} }
function fmt(s: number) { function fmt(s: number) {
const m = Math.floor(s / 60), sec = Math.floor(s % 60); const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? '0' : ''}${sec}`; return `${m}:${sec < 10 ? '0' : ''}${sec}`;
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.bg }, container: { flex: 1, backgroundColor: Colors.bg },
header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.md }, header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.md },
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 }, title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 },
count: { color: Colors.text3, fontSize: Font.sm }, count: { color: Colors.text3, fontSize: Font.sm },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
emptyIcon: { fontSize: 52, color: Colors.text3, marginBottom: 16 }, emptyIcon: { fontSize: 52, color: Colors.text3, marginBottom: 16 },
emptyTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: 8 }, emptyTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: 8 },
emptyText: { color: Colors.text2, fontSize: Font.sm, textAlign: 'center', lineHeight: 20 }, emptyText: { color: Colors.text2, fontSize: Font.sm, textAlign: 'center', lineHeight: 20 },
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm }, row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm },
rowActive: { backgroundColor: Colors.surface + '80' }, rowActive: { backgroundColor: Colors.surface + '80' },
cover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated }, cover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
info: { flex: 1 }, info: { flex: 1 },
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
likeBtn: { padding: 4 }, likeBtn: { padding: 4 },
}); });

View file

@ -1,125 +1,125 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
View, Text, StyleSheet, Pressable, ScrollView, Alert, View, Text, StyleSheet, Pressable, ScrollView, Alert,
} from 'react-native'; } from 'react-native';
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated'; import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Svg, { Path, Polyline } from 'react-native-svg'; import Svg, { Path, Polyline } from 'react-native-svg';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
import { useAuth } from '../../store'; import { useAuth } from '../../store';
import { logout } from '../../hooks/useAuth'; import { logout } from '../../hooks/useAuth';
export default function ProfileScreen() { export default function ProfileScreen() {
const { user } = useAuth(); const { user } = useAuth();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const handleLogout = useCallback(() => { const handleLogout = useCallback(() => {
Alert.alert('Выход', 'Выйти из аккаунта?', [ Alert.alert('Выход', 'Выйти из аккаунта?', [
{ text: 'Отмена', style: 'cancel' }, { text: 'Отмена', style: 'cancel' },
{ {
text: 'Выйти', style: 'destructive', text: 'Выйти', style: 'destructive',
onPress: async () => { onPress: async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
await logout(); await logout();
router.replace('/auth'); router.replace('/auth');
}, },
}, },
]); ]);
}, []); }, []);
if (!user) return null; if (!user) return null;
const initials = (user.username || user.email).slice(0, 2).toUpperCase(); const initials = (user.username || user.email).slice(0, 2).toUpperCase();
return ( return (
<ScrollView <ScrollView
style={styles.scroll} style={styles.scroll}
contentContainerStyle={[ contentContainerStyle={[
styles.content, styles.content,
{ paddingTop: insets.top + Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }, { paddingTop: insets.top + Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 },
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<Text style={styles.pageTitle}>Профиль</Text> <Text style={styles.pageTitle}>Профиль</Text>
<Animated.View entering={FadeIn.duration(500)} style={styles.avatarCard}> <Animated.View entering={FadeIn.duration(500)} style={styles.avatarCard}>
<LinearGradient colors={['#1e1e1e', '#141414']} style={styles.avatarCardGrad}> <LinearGradient colors={['#1e1e1e', '#141414']} style={styles.avatarCardGrad}>
{user.avatar_url ? ( {user.avatar_url ? (
<Image source={{ uri: user.avatar_url }} style={styles.avatar} contentFit="cover" /> <Image source={{ uri: user.avatar_url }} style={styles.avatar} contentFit="cover" />
) : ( ) : (
<View style={styles.avatarPlaceholder}> <View style={styles.avatarPlaceholder}>
<Text style={styles.avatarInitials}>{initials}</Text> <Text style={styles.avatarInitials}>{initials}</Text>
</View> </View>
)} )}
<Text style={styles.displayName}>{user.username || 'Пользователь'}</Text> <Text style={styles.displayName}>{user.username || 'Пользователь'}</Text>
<Text style={styles.email}>{user.email}</Text> <Text style={styles.email}>{user.email}</Text>
<View style={[styles.planBadge, user.plan !== 'free' && styles.planBadgePro]}> <View style={[styles.planBadge, user.plan !== 'free' && styles.planBadgePro]}>
<Text style={[styles.planText, user.plan !== 'free' && styles.planTextPro]}> <Text style={[styles.planText, user.plan !== 'free' && styles.planTextPro]}>
{user.plan === 'free' ? 'Free' : 'Pro'} {user.plan === 'free' ? 'Free' : 'Pro'}
</Text> </Text>
</View> </View>
</LinearGradient> </LinearGradient>
</Animated.View> </Animated.View>
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}> <Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
<Text style={styles.sectionLabel}>Аккаунт</Text> <Text style={styles.sectionLabel}>Аккаунт</Text>
<InfoRow label="Email" value={user.email} /> <InfoRow label="Email" value={user.email} />
<InfoRow label="Никнейм" value={user.username || '—'} /> <InfoRow label="Никнейм" value={user.username || '—'} />
<InfoRow label="Тариф" value={user.plan === 'free' ? 'Бесплатный' : 'Pro'} /> <InfoRow label="Тариф" value={user.plan === 'free' ? 'Бесплатный' : 'Pro'} />
</Animated.View> </Animated.View>
<Animated.View entering={FadeInDown.delay(200)}> <Animated.View entering={FadeInDown.delay(200)}>
<Pressable style={styles.logoutBtn} onPress={handleLogout}> <Pressable style={styles.logoutBtn} onPress={handleLogout}>
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none"> <Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
<Path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
<Polyline points="16,17 21,12 16,7" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Polyline points="16,17 21,12 16,7" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M21 12H9" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Path d="M21 12H9" stroke={Colors.red} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg> </Svg>
<Text style={styles.logoutText}>Выйти из аккаунта</Text> <Text style={styles.logoutText}>Выйти из аккаунта</Text>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
<Animated.Text entering={FadeInDown.delay(300)} style={styles.version}> <Animated.Text entering={FadeInDown.delay(300)} style={styles.version}>
Burmalda Music · v1.0.0 · by NodeShift Burmalda Music · v1.0.0 · by NodeShift
</Animated.Text> </Animated.Text>
</ScrollView> </ScrollView>
); );
} }
function InfoRow({ label, value }: { label: string; value: string }) { function InfoRow({ label, value }: { label: string; value: string }) {
return ( return (
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text> <Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue} numberOfLines={1}>{value}</Text> <Text style={styles.infoValue} numberOfLines={1}>{value}</Text>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: Colors.bg }, scroll: { flex: 1, backgroundColor: Colors.bg },
content: { paddingHorizontal: Spacing.md }, content: { paddingHorizontal: Spacing.md },
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: Spacing.lg }, pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: Spacing.lg },
avatarCard: { borderRadius: Radius.xl, overflow: 'hidden', marginBottom: Spacing.xl }, avatarCard: { borderRadius: Radius.xl, overflow: 'hidden', marginBottom: Spacing.xl },
avatarCardGrad: { padding: Spacing.xl, alignItems: 'center', gap: Spacing.sm }, avatarCardGrad: { padding: Spacing.xl, alignItems: 'center', gap: Spacing.sm },
avatar: { width: 88, height: 88, borderRadius: 44, marginBottom: Spacing.sm }, avatar: { width: 88, height: 88, borderRadius: 44, marginBottom: Spacing.sm },
avatarPlaceholder: { width: 88, height: 88, borderRadius: 44, backgroundColor: Colors.elevated, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.sm }, avatarPlaceholder: { width: 88, height: 88, borderRadius: 44, backgroundColor: Colors.elevated, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.sm },
avatarInitials: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800' }, avatarInitials: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800' },
displayName: { color: Colors.text, fontSize: Font.xl, fontWeight: '700' }, displayName: { color: Colors.text, fontSize: Font.xl, fontWeight: '700' },
email: { color: Colors.text2, fontSize: Font.sm }, email: { color: Colors.text2, fontSize: Font.sm },
planBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: Radius.full, backgroundColor: Colors.elevated, marginTop: 4 }, planBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: Radius.full, backgroundColor: Colors.elevated, marginTop: 4 },
planBadgePro: { backgroundColor: Colors.accent + '22', borderWidth: 1, borderColor: Colors.accent + '44' }, planBadgePro: { backgroundColor: Colors.accent + '22', borderWidth: 1, borderColor: Colors.accent + '44' },
planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' }, planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' },
planTextPro: { color: Colors.accent }, planTextPro: { color: Colors.accent },
section: { marginBottom: Spacing.lg }, section: { marginBottom: Spacing.lg },
sectionLabel: { color: Colors.text3, fontSize: Font.xs, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1, marginBottom: Spacing.sm }, sectionLabel: { color: Colors.text3, fontSize: Font.xs, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1, marginBottom: Spacing.sm },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.border }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.border },
infoLabel: { color: Colors.text2, fontSize: Font.md }, infoLabel: { color: Colors.text2, fontSize: Font.md },
infoValue: { color: Colors.text, fontSize: Font.md, fontWeight: '500', maxWidth: '55%', textAlign: 'right' }, infoValue: { color: Colors.text, fontSize: Font.md, fontWeight: '500', maxWidth: '55%', textAlign: 'right' },
logoutBtn: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, backgroundColor: Colors.surface, padding: Spacing.md, borderRadius: Radius.md, marginBottom: Spacing.lg }, logoutBtn: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, backgroundColor: Colors.surface, padding: Spacing.md, borderRadius: Radius.md, marginBottom: Spacing.lg },
logoutText: { color: Colors.red, fontSize: Font.md, fontWeight: '600' }, logoutText: { color: Colors.red, fontSize: Font.md, fontWeight: '600' },
version: { color: Colors.text3, fontSize: Font.xs, textAlign: 'center' }, version: { color: Colors.text3, fontSize: Font.xs, textAlign: 'center' },
}); });

View file

@ -1,130 +1,130 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard } from 'react-native'; import { View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard } from 'react-native';
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated'; import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
import { searchTracks, Track, coverUrl } from '../../services/api'; import { searchTracks, Track, coverUrl } from '../../services/api';
import { usePlayer, useLikes } from '../../store'; import { usePlayer, useLikes } from '../../store';
export default function SearchScreen() { export default function SearchScreen() {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<Track[]>([]); const [results, setResults] = useState<Track[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false); const [searched, setSearched] = useState(false);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { setQueue } = usePlayer(); const { setQueue } = usePlayer();
const { toggleLike, isLiked } = useLikes(); const { toggleLike, isLiked } = useLikes();
const doSearch = useCallback(async (q: string) => { const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); setSearched(false); return; } if (!q.trim()) { setResults([]); setSearched(false); return; }
setLoading(true); setLoading(true);
setSearched(true); setSearched(true);
const res = await searchTracks(q.trim()); const res = await searchTracks(q.trim());
setResults(res); setResults(res);
setLoading(false); setLoading(false);
}, []); }, []);
const playTrack = (index: number) => { const playTrack = (index: number) => {
Keyboard.dismiss(); Keyboard.dismiss();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setQueue(results, index); setQueue(results, index);
usePlayer.getState().setExpanded(true); usePlayer.getState().setExpanded(true);
}; };
return ( return (
<View style={[styles.container, { paddingTop: insets.top }]}> <View style={[styles.container, { paddingTop: insets.top }]}>
<Text style={styles.title}>Поиск</Text> <Text style={styles.title}>Поиск</Text>
<View style={styles.inputWrap}> <View style={styles.inputWrap}>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Треки, исполнители..." placeholder="Треки, исполнители..."
placeholderTextColor={Colors.text3} placeholderTextColor={Colors.text3}
value={query} value={query}
onChangeText={q => { setQuery(q); doSearch(q); }} onChangeText={q => { setQuery(q); doSearch(q); }}
returnKeyType="search" returnKeyType="search"
onSubmitEditing={() => doSearch(query)} onSubmitEditing={() => doSearch(query)}
autoCorrect={false} autoCorrect={false}
clearButtonMode="while-editing" clearButtonMode="while-editing"
/> />
</View> </View>
{!searched && ( {!searched && (
<Animated.View entering={FadeIn} style={styles.emptyWrap}> <Animated.View entering={FadeIn} style={styles.emptyWrap}>
<Text style={styles.emptyText}>Введите название трека или исполнителя</Text> <Text style={styles.emptyText}>Введите название трека или исполнителя</Text>
</Animated.View> </Animated.View>
)} )}
{searched && !loading && results.length === 0 && ( {searched && !loading && results.length === 0 && (
<Animated.View entering={FadeIn} style={styles.emptyWrap}> <Animated.View entering={FadeIn} style={styles.emptyWrap}>
<Text style={styles.emptyText}>Ничего не найдено</Text> <Text style={styles.emptyText}>Ничего не найдено</Text>
</Animated.View> </Animated.View>
)} )}
<FlatList <FlatList
data={results} data={results}
keyExtractor={t => String(t.id)} keyExtractor={t => String(t.id)}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.delay(index * 30).duration(250)}> <Animated.View entering={FadeInDown.delay(index * 30).duration(250)}>
<Pressable <Pressable
onPress={() => playTrack(index)} onPress={() => playTrack(index)}
style={[styles.row, usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id && styles.rowActive]} style={[styles.row, usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id && styles.rowActive]}
> >
{(() => { {(() => {
const cover = coverUrl(item.cover_url); const cover = coverUrl(item.cover_url);
const liked = isLiked(item.id); const liked = isLiked(item.id);
const active = usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id; const active = usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id;
return ( return (
<> <>
<Image <Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')} source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={styles.cover} style={styles.cover}
contentFit="cover" contentFit="cover"
transition={200} transition={200}
/> />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{item.title}</Text> <Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{item.title}</Text>
<Text style={styles.artist} numberOfLines={1}>{item.artist}</Text> <Text style={styles.artist} numberOfLines={1}>{item.artist}</Text>
</View> </View>
<Pressable onPress={() => toggleLike(item.id)} hitSlop={12}> <Pressable onPress={() => toggleLike(item.id)} hitSlop={12}>
<Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}> <Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg> </Svg>
</Pressable> </Pressable>
<Text style={styles.dur}>{fmt(item.duration)}</Text> <Text style={styles.dur}>{fmt(item.duration)}</Text>
</> </>
); );
})()} })()}
</Pressable> </Pressable>
</Animated.View> </Animated.View>
)} )}
/> />
</View> </View>
); );
} }
function fmt(s: number) { function fmt(s: number) {
const m = Math.floor(s / 60), sec = Math.floor(s % 60); const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? '0' : ''}${sec}`; return `${m}:${sec < 10 ? '0' : ''}${sec}`;
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.bg }, container: { flex: 1, backgroundColor: Colors.bg },
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, marginBottom: Spacing.md }, title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, marginBottom: Spacing.md },
inputWrap: { marginHorizontal: Spacing.md, marginBottom: Spacing.md, backgroundColor: Colors.surface, borderRadius: Radius.md, paddingHorizontal: Spacing.md }, inputWrap: { marginHorizontal: Spacing.md, marginBottom: Spacing.md, backgroundColor: Colors.surface, borderRadius: Radius.md, paddingHorizontal: Spacing.md },
input: { color: Colors.text, fontSize: Font.md, paddingVertical: 12 }, input: { color: Colors.text, fontSize: Font.md, paddingVertical: 12 },
emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 }, emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
emptyText: { color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, emptyText: { color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm }, row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm },
rowActive: { backgroundColor: Colors.surface }, rowActive: { backgroundColor: Colors.surface },
cover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated }, cover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
dur: { color: Colors.text3, fontSize: Font.xs }, dur: { color: Colors.text3, fontSize: Font.xs },
}); });

View file

@ -1,40 +1,40 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import * as Updates from 'expo-updates'; import * as Updates from 'expo-updates';
import { useBootAuth } from '../hooks/useAuth'; import { useBootAuth } from '../hooks/useAuth';
import { Colors } from '../constants/theme'; import { Colors } from '../constants/theme';
export default function RootLayout() { export default function RootLayout() {
useBootAuth(); useBootAuth();
useOTAUpdates(); useOTAUpdates();
return ( return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}> <GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
<StatusBar style="light" /> <StatusBar style="light" />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.bg } }}> <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.bg } }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="auth" options={{ presentation: 'fullScreenModal' }} /> <Stack.Screen name="auth" options={{ presentation: 'fullScreenModal' }} />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }
function useOTAUpdates() { function useOTAUpdates() {
useEffect(() => { useEffect(() => {
if (__DEV__) return; if (__DEV__) return;
(async () => { (async () => {
try { try {
const check = await Updates.checkForUpdateAsync(); const check = await Updates.checkForUpdateAsync();
if (check.isAvailable) { if (check.isAvailable) {
await Updates.fetchUpdateAsync(); await Updates.fetchUpdateAsync();
await Updates.reloadAsync(); await Updates.reloadAsync();
} }
// eslint-disable-next-line no-empty // eslint-disable-next-line no-empty
} catch { } catch {
// silent fail // silent fail
} }
})(); })();
}, []); }, []);
} }

View file

@ -1,165 +1,165 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { import {
View, Text, StyleSheet, Pressable, ActivityIndicator, Dimensions, View, Text, StyleSheet, Pressable, ActivityIndicator, Dimensions,
} from 'react-native'; } from 'react-native';
import Animated, { import Animated, {
useSharedValue, useAnimatedStyle, withSpring, withTiming, useSharedValue, useAnimatedStyle, withSpring, withTiming,
FadeIn, SlideInDown, FadeIn, SlideInDown,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import * as Linking from 'expo-linking'; import * as Linking from 'expo-linking';
import { router } from 'expo-router'; import { router } from 'expo-router';
import Svg, { Path, Circle, Polyline, Line } from 'react-native-svg'; import Svg, { Path, Circle, Polyline, Line } from 'react-native-svg';
import { Colors, Font, Radius, Spacing } from '../constants/theme'; import { Colors, Font, Radius, Spacing } from '../constants/theme';
import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api'; import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api';
import { useAuth } from '../store'; import { useAuth } from '../store';
import { saveUser } from '../hooks/useAuth'; import { saveUser } from '../hooks/useAuth';
WebBrowser.maybeCompleteAuthSession(); WebBrowser.maybeCompleteAuthSession();
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
export default function AuthScreen() { export default function AuthScreen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const { setUser } = useAuth(); const { setUser } = useAuth();
const btnScale = useSharedValue(1); const btnScale = useSharedValue(1);
const btnStyle = useAnimatedStyle(() => ({ const btnStyle = useAnimatedStyle(() => ({
transform: [{ scale: btnScale.value }], transform: [{ scale: btnScale.value }],
})); }));
const handleLogin = useCallback(async () => { const handleLogin = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
const url = buildOAuthUrl(state); const url = buildOAuthUrl(state);
const result = await WebBrowser.openAuthSessionAsync(url, 'burmalda://auth/callback'); const result = await WebBrowser.openAuthSessionAsync(url, 'burmalda://auth/callback');
if (result.type !== 'success') { if (result.type !== 'success') {
setError('Авторизация отменена'); setError('Авторизация отменена');
return; return;
} }
const parsed = Linking.parse(result.url); const parsed = Linking.parse(result.url);
const code = parsed.queryParams?.code as string | undefined; const code = parsed.queryParams?.code as string | undefined;
if (!code) { if (!code) {
setError((parsed.queryParams?.error as string) || 'Ошибка авторизации'); setError((parsed.queryParams?.error as string) || 'Ошибка авторизации');
return; return;
} }
const data = await exchangeCode(code); const data = await exchangeCode(code);
await saveTokens(data.access_token, data.refresh_token); await saveTokens(data.access_token, data.refresh_token);
await saveUser(data.user); await saveUser(data.user);
setUser(data.user); setUser(data.user);
router.replace('/(tabs)'); router.replace('/(tabs)');
} catch (e: any) { } catch (e: any) {
setError(e.message || 'Что-то пошло не так'); setError(e.message || 'Что-то пошло не так');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [setUser]); }, [setUser]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Blob color={Colors.accent} size={420} left={-80} top={-100} delay={0} /> <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="#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} /> <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 style={styles.card} entering={FadeIn.duration(600)}>
<Animated.View entering={SlideInDown.delay(100).springify()} style={styles.logoWrap}> <Animated.View entering={SlideInDown.delay(100).springify()} style={styles.logoWrap}>
<LinearGradient colors={[Colors.accent, '#2a5cc4']} 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"> <Svg width={38} height={38} viewBox="0 0 24 24" fill="none">
<Path d="M9 18V5l12-2v13" stroke="#fff" strokeWidth={2} strokeLinecap="round" /> <Path d="M9 18V5l12-2v13" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
<Circle cx={6} cy={18} r={3} fill="#fff" /> <Circle cx={6} cy={18} r={3} fill="#fff" />
<Circle cx={18} cy={16} r={3} fill="#fff" /> <Circle cx={18} cy={16} r={3} fill="#fff" />
</Svg> </Svg>
</LinearGradient> </LinearGradient>
<Text style={styles.logoTitle}>Burmalda Music</Text> <Text style={styles.logoTitle}>Burmalda Music</Text>
<Text style={styles.logoSub}>by NodeShift</Text> <Text style={styles.logoSub}>by NodeShift</Text>
</Animated.View> </Animated.View>
<Animated.View entering={SlideInDown.delay(200).springify()} style={{ width: '100%' }}> <Animated.View entering={SlideInDown.delay(200).springify()} style={{ width: '100%' }}>
<Animated.View style={btnStyle}> <Animated.View style={btnStyle}>
<Pressable <Pressable
style={styles.btnWrap} style={styles.btnWrap}
disabled={loading} disabled={loading}
onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 14 }); }} onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 14 }); }}
onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }} onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }}
onPress={handleLogin} onPress={handleLogin}
> >
<LinearGradient colors={[Colors.accent, '#2a5cc4']} 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 {loading
? <ActivityIndicator color="#fff" size="small" /> ? <ActivityIndicator color="#fff" size="small" />
: ( : (
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none"> <Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<Path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> <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" /> <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" /> <Line x1="12" y1="13" x2="12" y2="21" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
</Svg> </Svg>
)} )}
<Text style={styles.btnText}> <Text style={styles.btnText}>
{loading ? 'Ожидаем авторизацию…' : 'Войти через NodeShift'} {loading ? 'Ожидаем авторизацию…' : 'Войти через NodeShift'}
</Text> </Text>
</LinearGradient> </LinearGradient>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
{!!error && ( {!!error && (
<Animated.Text entering={FadeIn} style={styles.error}>{error}</Animated.Text> <Animated.Text entering={FadeIn} style={styles.error}>{error}</Animated.Text>
)} )}
</Animated.View> </Animated.View>
<Animated.Text entering={FadeIn.delay(300)} style={styles.footer}> <Animated.Text entering={FadeIn.delay(300)} style={styles.footer}>
Нажимая «Войти», вы соглашаетесь с{'\n'}условиями использования NodeShift Нажимая «Войти», вы соглашаетесь с{'\n'}условиями использования NodeShift
</Animated.Text> </Animated.Text>
</Animated.View> </Animated.View>
</View> </View>
); );
} }
function Blob({ color, size, left, top, delay }: { function Blob({ color, size, left, top, delay }: {
color: string; size: number; left: number; top: number; delay: number; color: string; size: number; left: number; top: number; delay: number;
}) { }) {
const tx = useSharedValue(0); const tx = useSharedValue(0);
const ty = useSharedValue(0); const ty = useSharedValue(0);
useEffect(() => { useEffect(() => {
const animate = () => { const animate = () => {
tx.value = withTiming(25, { duration: 8000 }, () => tx.value = withTiming(25, { duration: 8000 }, () =>
tx.value = withTiming(-20, { duration: 8000 }, () => tx.value = withTiming(-20, { duration: 8000 }, () =>
tx.value = withTiming(0, { duration: 8000 }, animate))); tx.value = withTiming(0, { duration: 8000 }, animate)));
ty.value = withTiming(-35, { duration: 8000 }, () => ty.value = withTiming(-35, { duration: 8000 }, () =>
ty.value = withTiming(18, { duration: 8000 }, () => ty.value = withTiming(18, { duration: 8000 }, () =>
ty.value = withTiming(0, { duration: 8000 }))); ty.value = withTiming(0, { duration: 8000 })));
}; };
const t = setTimeout(animate, delay); const t = setTimeout(animate, delay);
return () => clearTimeout(t); return () => clearTimeout(t);
}, []); }, []);
const style = useAnimatedStyle(() => ({ const style = useAnimatedStyle(() => ({
transform: [{ translateX: tx.value }, { translateY: ty.value }], transform: [{ translateX: tx.value }, { translateY: ty.value }],
})); }));
return ( return (
<Animated.View style={[styles.blob, style, { <Animated.View style={[styles.blob, style, {
width: size, height: size, borderRadius: size / 2, width: size, height: size, borderRadius: size / 2,
backgroundColor: color, left, top, backgroundColor: color, left, top,
}]} /> }]} />
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }, container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
blob: { position: 'absolute', opacity: 0.12 }, blob: { position: 'absolute', opacity: 0.12 },
card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 }, card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 },
logoWrap: { alignItems: 'center', marginBottom: 40 }, 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 }, 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 }, logoTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: 4 },
logoSub: { color: Colors.text3, fontSize: Font.sm, fontWeight: '500' }, 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 }, 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 }, btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 },
btnText: { color: '#fff', 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 }, 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 }, footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 },
}); });

View file

@ -1,7 +1,7 @@
module.exports = function (api) { module.exports = function (api) {
api.cache(true); api.cache(true);
return { return {
presets: ['babel-preset-expo'], presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'], plugins: ['react-native-reanimated/plugin'],
}; };
}; };

View file

@ -1,56 +1,56 @@
name: Build iOS IPA name: Build iOS IPA
on: on:
push: push:
branches: [main, master] branches: [main, master]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node 20 - name: Setup Node 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Expo prebuild - name: Expo prebuild
run: npx expo prebuild --platform ios --clean run: npx expo prebuild --platform ios --clean
env: env:
EXPO_NO_DOTENV: 1 EXPO_NO_DOTENV: 1
- name: Build unsigned archive - name: Build unsigned archive
run: | run: |
xcodebuild archive \ xcodebuild archive \
-workspace "ios/Burmalda Music.xcworkspace" \ -workspace "ios/Burmalda Music.xcworkspace" \
-scheme "Burmalda Music" \ -scheme "Burmalda Music" \
-configuration Release \ -configuration Release \
-sdk iphoneos \ -sdk iphoneos \
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \ -archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \ CODE_SIGN_IDENTITY="" \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
ENABLE_BITCODE=NO \ ENABLE_BITCODE=NO \
| xcpretty || true | xcpretty || true
- name: Package IPA - name: Package IPA
run: | run: |
mkdir -p Payload mkdir -p Payload
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/ cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/
zip -qr burmalda-unsigned.ipa Payload zip -qr burmalda-unsigned.ipa Payload
rm -rf Payload rm -rf Payload
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: burmalda-unsigned-ipa name: burmalda-unsigned-ipa
path: burmalda-unsigned.ipa path: burmalda-unsigned.ipa
retention-days: 30 retention-days: 30

85
build.yml Normal file
View file

@ -0,0 +1,85 @@
name: Build & Deploy iOS
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
env:
NODE_VERSION: '20'
EXPO_VERSION: 'latest'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup Expo
run: npm install -g expo-cli eas-cli
- name: Lint & Type Check
run: |
npm run type-check 2>/dev/null || true
npm run lint 2>/dev/null || true
- name: Build Preview (EAS)
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
env:
EAS_TOKEN: ${{ secrets.EAS_TOKEN }}
run: |
eas build --platform ios --profile preview --non-interactive --wait
- name: Build Production (EAS)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
EAS_TOKEN: ${{ secrets.EAS_TOKEN }}
run: |
eas build --platform ios --profile production --non-interactive --wait
- name: Release Notes
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
echo "### Build Complete ✅" >> $GITHUB_STEP_SUMMARY
echo "App version: $(cat package.json | grep version | head -1 | awk -F= '{ print $2 }' | sed 's/[version",]//g')" >> $GITHUB_STEP_SUMMARY
echo "Built for: iOS" >> $GITHUB_STEP_SUMMARY
echo "Download IPA from EAS Dashboard" >> $GITHUB_STEP_SUMMARY
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Check TypeScript
run: npx tsc --noEmit || true
- name: Validate structure
run: |
test -f app.json && echo "✓ app.json exists"
test -f tsconfig.json && echo "✓ tsconfig.json exists"
test -d app && echo "✓ app directory exists"
test -d services && echo "✓ services directory exists"
test -d components && echo "✓ components directory exists"

View file

@ -1,73 +1,73 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Text, ViewStyle } from 'react-native'; import { Text, ViewStyle } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Radius } from '../constants/theme'; import { Radius } from '../constants/theme';
// 12 distinct dark gradients — one is assigned per title hash // 12 distinct dark gradients — one is assigned per title hash
const GRADIENTS: [string, string][] = [ const GRADIENTS: [string, string][] = [
['#12234a', '#1e4080'], ['#12234a', '#1e4080'],
['#1a0e3a', '#3d1a7a'], ['#1a0e3a', '#3d1a7a'],
['#0e2a3a', '#1a5a7a'], ['#0e2a3a', '#1a5a7a'],
['#2a0e1e', '#6a1a3e'], ['#2a0e1e', '#6a1a3e'],
['#0e2a1e', '#1a6a3e'], ['#0e2a1e', '#1a6a3e'],
['#2a1e0e', '#7a4a1e'], ['#2a1e0e', '#7a4a1e'],
['#1e0e2a', '#4a1e6a'], ['#1e0e2a', '#4a1e6a'],
['#0e1e2a', '#1e4a6a'], ['#0e1e2a', '#1e4a6a'],
['#2a0e2a', '#6a1a6a'], ['#2a0e2a', '#6a1a6a'],
['#1e2a0e', '#4a6a1e'], ['#1e2a0e', '#4a6a1e'],
['#2a1e1e', '#6a3a3a'], ['#2a1e1e', '#6a3a3a'],
['#0e2a2a', '#1a6a6a'], ['#0e2a2a', '#1a6a6a'],
]; ];
function textHash(s: string): number { function textHash(s: string): number {
let h = 0; let h = 0;
for (const c of s) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0; for (const c of s) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0;
return Math.abs(h); return Math.abs(h);
} }
interface Props { interface Props {
uri: string | null; uri: string | null;
title: string; title: string;
size: number; size: number;
radius?: number; radius?: number;
style?: ViewStyle; style?: ViewStyle;
blurRadius?: number; blurRadius?: number;
} }
export default function CoverImage({ uri, title, size, radius = Radius.sm, style, blurRadius }: Props) { export default function CoverImage({ uri, title, size, radius = Radius.sm, style, blurRadius }: Props) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
// Reset error state when URI changes (new track loaded) // Reset error state when URI changes (new track loaded)
useEffect(() => { setFailed(false); }, [uri]); useEffect(() => { setFailed(false); }, [uri]);
if (uri && !failed) { if (uri && !failed) {
return ( return (
<Image <Image
source={{ uri }} source={{ uri }}
style={[{ width: size, height: size, borderRadius: radius, backgroundColor: '#1a1a1a' }, style]} style={[{ width: size, height: size, borderRadius: radius, backgroundColor: '#1a1a1a' }, style]}
contentFit="cover" contentFit="cover"
transition={250} transition={250}
blurRadius={blurRadius} blurRadius={blurRadius}
onError={() => setFailed(true)} onError={() => setFailed(true)}
/> />
); );
} }
const [c1, c2] = GRADIENTS[textHash(title || '') % GRADIENTS.length]; const [c1, c2] = GRADIENTS[textHash(title || '') % GRADIENTS.length];
const letter = (title || '?').trim().toUpperCase()[0]; const letter = (title || '?').trim().toUpperCase()[0];
const fontSize = size * 0.36; const fontSize = size * 0.36;
return ( return (
<LinearGradient <LinearGradient
colors={[c1, c2]} colors={[c1, c2]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={[{ width: size, height: size, borderRadius: radius, alignItems: 'center', justifyContent: 'center' }, style]} style={[{ width: size, height: size, borderRadius: radius, alignItems: 'center', justifyContent: 'center' }, style]}
> >
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize, fontWeight: '800', textAlign: 'center' }}> <Text style={{ color: 'rgba(255,255,255,0.85)', fontSize, fontWeight: '800', textAlign: 'center' }}>
{letter} {letter}
</Text> </Text>
</LinearGradient> </LinearGradient>
); );
} }

View file

@ -0,0 +1,101 @@
import { View, StyleSheet, Pressable, Text, ActivityIndicator } from 'react-native';
import Animated, { FadeIn, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { useMemo } from 'react';
import Svg, { Path } from 'react-native-svg';
import * as Haptics from 'expo-haptics';
import { Colors, Font } from '../constants/theme';
export interface DownloadButtonProps {
trackId: number;
isDownloaded?: boolean;
downloadProgress?: number; // 0-1
onDownload?: () => void;
onDelete?: () => void;
size?: 'sm' | 'md' | 'lg';
}
export default function DownloadButton({
trackId,
isDownloaded = false,
downloadProgress = 0,
onDownload,
onDelete,
size = 'md',
}: DownloadButtonProps) {
const sizing = useMemo(() => {
const map = { sm: 20, md: 24, lg: 32 };
return map[size];
}, [size]);
const handlePress = async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
if (isDownloaded) {
onDelete?.();
} else {
onDownload?.();
}
};
const isLoading = downloadProgress > 0 && downloadProgress < 1;
if (isLoading) {
return (
<Animated.View entering={FadeIn}>
<View style={[styles.container, { width: sizing, height: sizing }]}>
<ActivityIndicator color={Colors.accent} size={sizing * 0.6} />
{downloadProgress > 0.2 && (
<Text style={[styles.progress, { fontSize: sizing * 0.3 }]}>
{Math.round(downloadProgress * 100)}%
</Text>
)}
</View>
</Animated.View>
);
}
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [
styles.button,
{
width: sizing,
height: sizing,
opacity: pressed ? 0.6 : 1,
},
]}
>
{isDownloaded ? (
<Svg width={sizing} height={sizing} viewBox="0 0 24 24" fill="none">
<Path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"
fill={Colors.accent}
/>
</Svg>
) : (
<Svg width={sizing} height={sizing} viewBox="0 0 24 24" fill="none">
<Path
d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"
fill={Colors.text2}
/>
</Svg>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
justifyContent: 'center',
alignItems: 'center',
},
container: {
justifyContent: 'center',
alignItems: 'center',
},
progress: {
color: Colors.accent,
fontFamily: Font.family.medium,
marginTop: 2,
},
});

View file

@ -1,129 +1,129 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native'; import { View, Text, StyleSheet, Pressable } from 'react-native';
import Animated, { import Animated, {
useSharedValue, useAnimatedStyle, withSpring, useSharedValue, useAnimatedStyle, withSpring,
FadeInDown, FadeOutDown, useAnimatedGestureHandler, runOnJS, FadeInDown, FadeOutDown, useAnimatedGestureHandler, runOnJS,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { PanGestureHandler } from 'react-native-gesture-handler'; import { PanGestureHandler } from 'react-native-gesture-handler';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme'; import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme';
import { usePlayer, useLikes } from '../store'; import { usePlayer, useLikes } from '../store';
import { togglePlay, seekTo } from '../hooks/useAudio'; import { togglePlay, seekTo } from '../hooks/useAudio';
import { coverUrl } from '../services/api'; import { coverUrl } from '../services/api';
export default function MiniPlayer() { export default function MiniPlayer() {
const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer(); const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer();
const { toggleLike, isLiked } = useLikes(); const { toggleLike, isLiked } = useLikes();
const track = queue[index]; const track = queue[index];
const translateY = useSharedValue(0); const translateY = useSharedValue(0);
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const gestureHandler = useAnimatedGestureHandler({ const gestureHandler = useAnimatedGestureHandler({
onActive: (e) => { onActive: (e) => {
if (e.translationY < 0) { if (e.translationY < 0) {
translateY.value = Math.max(e.translationY, -80); translateY.value = Math.max(e.translationY, -80);
opacity.value = 1 + e.translationY / 80; opacity.value = 1 + e.translationY / 80;
} }
}, },
onEnd: (e) => { onEnd: (e) => {
if (e.translationY < -40 || e.velocityY < -400) runOnJS(openPlayer)(); if (e.translationY < -40 || e.velocityY < -400) runOnJS(openPlayer)();
translateY.value = withSpring(0); translateY.value = withSpring(0);
opacity.value = withSpring(1); opacity.value = withSpring(1);
}, },
}); });
const animStyle = useAnimatedStyle(() => ({ const animStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }], transform: [{ translateY: translateY.value }],
opacity: opacity.value, opacity: opacity.value,
})); }));
const openPlayer = useCallback(() => { const openPlayer = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setExpanded(true); setExpanded(true);
}, [setExpanded]); }, [setExpanded]);
const onNext = useCallback(() => { const onNext = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const ni = usePlayer.getState().nextTrack(); const ni = usePlayer.getState().nextTrack();
usePlayer.getState().setIndex(ni); usePlayer.getState().setIndex(ni);
}, []); }, []);
const onPrev = useCallback(() => { const onPrev = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const pi = usePlayer.getState().prevTrack(); const pi = usePlayer.getState().prevTrack();
usePlayer.getState().setIndex(pi); usePlayer.getState().setIndex(pi);
}, []); }, []);
if (!track || expanded) return null; if (!track || expanded) return null;
const cover = coverUrl(track.cover_url); const cover = coverUrl(track.cover_url);
const liked = isLiked(track.id); const liked = isLiked(track.id);
const pct = duration > 0 ? (progress / duration) * 100 : 0; const pct = duration > 0 ? (progress / duration) * 100 : 0;
return ( return (
<PanGestureHandler onGestureEvent={gestureHandler}> <PanGestureHandler onGestureEvent={gestureHandler}>
<Animated.View <Animated.View
style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]} style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
entering={FadeInDown.springify()} entering={FadeInDown.springify()}
exiting={FadeOutDown.springify()} exiting={FadeOutDown.springify()}
> >
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} /> <BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
<View style={[StyleSheet.absoluteFill, s.bgTint]} /> <View style={[StyleSheet.absoluteFill, s.bgTint]} />
{/* Progress line */} {/* Progress line */}
<View style={s.progressBg}> <View style={s.progressBg}>
<View style={[s.progressFill, { width: `${pct}%` as any }]} /> <View style={[s.progressFill, { width: `${pct}%` as any }]} />
</View> </View>
<Pressable style={s.row} onPress={openPlayer} android_ripple={null}> <Pressable style={s.row} onPress={openPlayer} android_ripple={null}>
<Image <Image
source={cover ? { uri: cover } : require('../assets/placeholder.png')} source={cover ? { uri: cover } : require('../assets/placeholder.png')}
style={s.cover} style={s.cover}
contentFit="cover" contentFit="cover"
transition={200} transition={200}
/> />
<View style={s.info}> <View style={s.info}>
<Text style={s.title} numberOfLines={1}>{track.title}</Text> <Text style={s.title} numberOfLines={1}>{track.title}</Text>
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text> <Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
</View> </View>
<View style={s.controls}> <View style={s.controls}>
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}> <Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}>
<Svg width={18} height={18} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}> <Svg width={18} height={18} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text2} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> <Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text2} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</Svg> </Svg>
</Pressable> </Pressable>
<Pressable onPress={(e) => { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}> <Pressable onPress={(e) => { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}>
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></Svg> <Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></Svg>
</Pressable> </Pressable>
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}> <Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}>
{playing {playing
? <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg> ? <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg>
: <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>} : <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>}
</Pressable> </Pressable>
<Pressable onPress={(e) => { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}> <Pressable onPress={(e) => { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}>
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg> <Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg>
</Pressable> </Pressable>
</View> </View>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
</PanGestureHandler> </PanGestureHandler>
); );
} }
const s = StyleSheet.create({ const s = StyleSheet.create({
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderTopLeftRadius: 14, borderTopRightRadius: 14 }, wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderTopLeftRadius: 14, borderTopRightRadius: 14 },
bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' }, bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' },
progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 }, progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 },
progressFill:{ height: 2, backgroundColor: Colors.accent }, progressFill:{ height: 2, backgroundColor: Colors.accent },
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 }, row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 },
cover: { width: 44, height: 44, borderRadius: 9, backgroundColor: Colors.elevated, flexShrink: 0 }, cover: { width: 44, height: 44, borderRadius: 9, backgroundColor: Colors.elevated, flexShrink: 0 },
info: { flex: 1, minWidth: 0 }, info: { flex: 1, minWidth: 0 },
title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 }, artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
controls: { flexDirection: 'row', alignItems: 'center' }, controls: { flexDirection: 'row', alignItems: 'center' },
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
}); });

View file

@ -1,316 +1,480 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView } from 'react-native'; import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView, ViewStyle } from 'react-native';
import Animated, { import Animated, {
useSharedValue, useAnimatedStyle, withSpring, useSharedValue, useAnimatedStyle, withSpring, withDecay,
useAnimatedGestureHandler, runOnJS, FadeIn, interpolate, useAnimatedGestureHandler, runOnJS, FadeIn, interpolate, Easing, withTiming,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { PanGestureHandler } from 'react-native-gesture-handler'; import { PanGestureHandler } from 'react-native-gesture-handler';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import Slider from '@react-native-community/slider'; import Slider from '@react-native-community/slider';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import Svg, { Path, Polyline, Line, Rect } from 'react-native-svg'; import Svg, { Path, Polyline, Line, Rect, Circle } from 'react-native-svg';
import { Colors, Font, Radius } from '../constants/theme'; import { Colors, Font, Radius } from '../constants/theme';
import { usePlayer, useLikes } from '../store'; import { usePlayer, useLikes } from '../store';
import { togglePlay, seekTo } from '../hooks/useAudio'; import { togglePlay, seekTo } from '../hooks/useAudio';
import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api'; import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api';
const { width } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
const COVER = width - 48; const COVER = width - 48;
const MIN_SWIPE_DISTANCE = 50;
export default function Player() { const MAX_SWIPE_DISTANCE = height * 0.4;
const {
queue, index, playing, progress, duration, export default function Player() {
repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle, const {
} = usePlayer(); queue, index, playing, progress, duration,
const { toggleLike, isLiked } = useLikes(); repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle,
const insets = useSafeAreaInsets(); } = usePlayer();
const track = queue[index]; const { toggleLike, isLiked } = useLikes();
const insets = useSafeAreaInsets();
const [lyricsOpen, setLyricsOpen] = useState(false); const track = queue[index];
const [lrcLines, setLrcLines] = useState<LrcLine[]>([]);
const [plainLyrics, setPlain] = useState<string | null>(null); const [lyricsOpen, setLyricsOpen] = useState(false);
const [lyricsLoading, setLyricsLoading] = useState(false); const [lrcLines, setLrcLines] = useState<LrcLine[]>([]);
const lyricsScrollRef = useRef<ScrollView>(null); const [plainLyrics, setPlain] = useState<string | null>(null);
const lineHeights = useRef<number[]>([]); const [lyricsLoading, setLyricsLoading] = useState(false);
const lyricsScrollRef = useRef<ScrollView>(null);
const translateY = useSharedValue(0); const lineHeights = useRef<number[]>([]);
const coverScale = useSharedValue(1); const gestureRef = useRef<any>(null);
useEffect(() => { const translateY = useSharedValue(0);
coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 }); const coverScale = useSharedValue(1);
}, [playing]); const coverRotate = useSharedValue(0);
useEffect(() => { useEffect(() => {
setLrcLines([]); setPlain(null); setLyricsOpen(false); coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 14, mass: 1, stiffness: 100 });
}, [track?.id]); }, [playing]);
const loadLyrics = useCallback(async () => { useEffect(() => {
if (!track) return; setLrcLines([]); setPlain(null); setLyricsOpen(false);
if (lrcLines.length > 0 || plainLyrics) { setLyricsOpen(true); return; } translateY.value = 0;
setLyricsLoading(true); }, [track?.id]);
const data = await fetchLyrics(track.id);
setLyricsLoading(false); const loadLyrics = useCallback(async () => {
if (data?.synced) setLrcLines(parseLrc(data.synced)); if (!track) return;
else if (data?.plain) setPlain(data.plain); if (lrcLines.length > 0 || plainLyrics) { setLyricsOpen(true); return; }
setLyricsOpen(true); setLyricsLoading(true);
}, [track, lrcLines.length, plainLyrics]); const data = await fetchLyrics(track.id);
setLyricsLoading(false);
const currentLineIdx = lrcLines.length > 0 if (data?.synced) setLrcLines(parseLrc(data.synced));
? lrcLines.reduce((acc, line, i) => line.time <= progress ? i : acc, 0) else if (data?.plain) setPlain(data.plain);
: -1; setLyricsOpen(true);
}, [track, lrcLines.length, plainLyrics]);
useEffect(() => {
if (!lyricsOpen || currentLineIdx < 0) return; const currentLineIdx = lrcLines.length > 0
const y = lineHeights.current.slice(0, currentLineIdx).reduce((sum, h) => sum + h, 0); ? lrcLines.reduce((acc, line, i) => line.time <= progress ? i : acc, 0)
lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 120), animated: true }); : -1;
}, [currentLineIdx, lyricsOpen]);
useEffect(() => {
const gestureHandler = useAnimatedGestureHandler<any, { startY: number }>({ if (!lyricsOpen || currentLineIdx < 0) return;
onStart: (_, ctx) => { ctx.startY = translateY.value; }, const y = lineHeights.current.slice(0, currentLineIdx).reduce((sum, h) => sum + h, 0);
onActive: (e, ctx) => { if (e.translationY > 0) translateY.value = ctx.startY + e.translationY; }, lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 100), animated: true });
onEnd: (e) => { }, [currentLineIdx, lyricsOpen]);
if (e.translationY > 100 || e.velocityY > 700) runOnJS(close)();
else translateY.value = withSpring(0, { damping: 20 }); const gestureHandler = useAnimatedGestureHandler<any, { startY: number, startX: number }>({
}, onStart: (_, ctx) => {
}); ctx.startY = translateY.value;
ctx.startX = 0;
const containerStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }] })); },
const overlayOpacity = useAnimatedStyle(() => ({ opacity: interpolate(translateY.value, [0, 300], [1, 0]) })); onActive: (e, ctx) => {
const coverStyle = useAnimatedStyle(() => ({ transform: [{ scale: coverScale.value }] })); try {
// Only allow vertical swipe, prevent diagonal
const close = useCallback(() => { if (Math.abs(e.translationX) > Math.abs(e.translationY) * 0.5) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setExpanded(false); if (e.translationY > 0) {
}, [setExpanded]); const clamped = Math.min(e.translationY, MAX_SWIPE_DISTANCE);
translateY.value = ctx.startY + clamped;
const onNext = useCallback(() => { coverRotate.value = withTiming(clamped / 100, { duration: 50, easing: Easing.linear });
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }
const ni = usePlayer.getState().nextTrack(); } catch (err) {
usePlayer.getState().setIndex(ni); console.warn('Gesture error:', err);
}, []); }
},
const onPrev = useCallback(() => { onEnd: (e) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); try {
if (progress > 3) { seekTo(0); return; } const shouldClose = e.translationY > MIN_SWIPE_DISTANCE || e.velocityY > 500;
const pi = usePlayer.getState().prevTrack();
usePlayer.getState().setIndex(pi); if (shouldClose) {
}, [progress]); translateY.value = withTiming(height, {
duration: 300,
if (!track) return null; easing: Easing.out(Easing.ease)
}, () => {
const cover = coverUrl(track.cover_url); runOnJS(close)();
const pct = duration > 0 ? progress / duration : 0; });
const liked = isLiked(track.id); coverRotate.value = withTiming(0, { duration: 300 });
} else {
return ( translateY.value = withSpring(0, {
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}> damping: 15,
{/* Blurred background */} mass: 1,
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={80} /> stiffness: 120
<View style={[StyleSheet.absoluteFill, s.bgDim]} /> });
<BlurView intensity={20} tint="dark" style={StyleSheet.absoluteFill} /> coverRotate.value = withTiming(0, { duration: 200 });
}
<PanGestureHandler onGestureEvent={gestureHandler}> } catch (err) {
<Animated.View style={[s.sheet, containerStyle, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 16 }]}> console.warn('Gesture end error:', err);
translateY.value = withSpring(0, { damping: 15, mass: 1 });
{/* Top bar */} }
<View style={s.topBar}> },
<Text style={s.topLabel}>Сейчас играет</Text> });
<Pressable onPress={close} hitSlop={16} style={s.closeBtn}>
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none"> const containerStyle = useAnimatedStyle(() => ({
<Path d="M18 6L6 18M6 6l12 12" stroke="rgba(255,255,255,0.5)" strokeWidth={2} strokeLinecap="round" /> transform: [{ translateY: translateY.value }]
</Svg> }));
</Pressable>
</View> const overlayOpacity = useAnimatedStyle(() => ({
opacity: interpolate(translateY.value, [0, MAX_SWIPE_DISTANCE], [1, 0.2])
{/* Cover */} }));
<View style={s.coverWrap}>
<Animated.View style={[s.coverShadow, coverStyle]}> const coverStyle = useAnimatedStyle(() => ({
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={s.cover} contentFit="cover" transition={300} /> transform: [
</Animated.View> { scale: coverScale.value },
</View> { rotateZ: `${coverRotate.value}deg` }
]
{/* Meta */} }));
<View style={s.metaRow}>
<View style={s.metaText}> const close = useCallback(() => {
<Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text> Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text> setExpanded(false);
</View> }, [setExpanded]);
<Pressable onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={12}>
<Svg width={22} height={22} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}> const onNext = useCallback(() => {
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : 'rgba(255,255,255,0.4)'} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" /> Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
</Svg> const ni = usePlayer.getState().nextTrack();
</Pressable> usePlayer.getState().setIndex(ni);
</View> }, []);
{/* Seek */} const onPrev = useCallback(() => {
<View style={s.seekWrap}> Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
<Slider style={{ width: '100%', height: 32 }} minimumValue={0} maximumValue={1} value={pct} minimumTrackTintColor={Colors.text} maximumTrackTintColor="rgba(255,255,255,0.2)" thumbTintColor={Colors.text} onSlidingComplete={(v) => seekTo(v * duration)} /> if (progress > 3) { seekTo(0); return; }
<View style={s.timeRow}> const pi = usePlayer.getState().prevTrack();
<Text style={s.timeText}>{fmt(progress)}</Text> usePlayer.getState().setIndex(pi);
<Text style={s.timeText}>{fmt(duration)}</Text> }, [progress]);
</View>
</View> if (!track) return null;
{/* Controls */} const cover = coverUrl(track.cover_url);
<View style={s.controls}> const pct = duration > 0 ? progress / duration : 0;
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}> const liked = isLiked(track.id);
<ShuffleIcon active={shuffle} />
</CtlBtn> return (
<CtlBtn onPress={onPrev}> <Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}>
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none"> {/* Blurred background with better gradient */}
<Rect x={5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} /> <Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={100} />
<Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} /> <View style={[StyleSheet.absoluteFill, s.bgDim]} />
</Svg> <BlurView intensity={25} tint="dark" style={StyleSheet.absoluteFill} />
</CtlBtn>
<Pressable style={s.playBtn} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}> <PanGestureHandler ref={gestureRef} onGestureEvent={gestureHandler}>
{playing <Animated.View style={[s.sheet, containerStyle, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 16 }]}>
? <Svg width={26} height={26} viewBox="0 0 24 24"><Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /><Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /></Svg>
: <Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" /></Svg>} {/* Top bar with better design */}
</Pressable> <View style={s.topBar}>
<CtlBtn onPress={onNext}> <Text style={s.topLabel}>Сейчас играет</Text>
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none"> <Pressable onPress={close} hitSlop={20} style={s.closeBtn}>
<Rect x={20.5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} /> <Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
<Path d="M6 6l12.5 8L6 22V6z" fill={Colors.text} /> <Path d="M18 6L6 18M6 6l12 12" stroke="rgba(255,255,255,0.6)" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
</Svg> </Svg>
</CtlBtn> </Pressable>
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}> </View>
<RepeatIcon repeat={repeat} />
</CtlBtn> {/* Cover with better shadow */}
</View> <View style={s.coverWrap}>
<Animated.View style={[s.coverShadow, coverStyle]}>
{/* Lyrics button */} <Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={s.cover} contentFit="cover" transition={300} />
<View style={s.bottomRow}> </Animated.View>
<Pressable style={s.lyrBtn} onPress={loadLyrics}> </View>
<Svg width={14} height={14} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.4)'} strokeWidth={2} viewBox="0 0 24 24">
<Line x1={3} y1={8} x2={21} y2={8} /><Line x1={3} y1={12} x2={21} y2={12} /><Line x1={3} y1={16} x2={15} y2={16} /> {/* Meta with better spacing */}
</Svg> <View style={s.metaRow}>
<Text style={[s.lyrBtnText, lyricsOpen && { color: Colors.accent }]}> <View style={s.metaText}>
{lyricsLoading ? 'Загрузка…' : 'Текст'} <Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text>
</Text> <Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
</Pressable> </View>
</View> <Pressable onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={16}>
<Svg width={24} height={24} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
</Animated.View> <Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : 'rgba(255,255,255,0.5)'} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</PanGestureHandler> </Svg>
</Pressable>
{/* Lyrics sheet */} </View>
{lyricsOpen && (
<View style={s.lyrSheet}> {/* Seek with gradient track */}
<Pressable style={StyleSheet.absoluteFill} onPress={() => setLyricsOpen(false)} /> <View style={s.seekWrap}>
<BlurView intensity={40} tint="dark" style={s.lyrInner}> <View style={s.sliderContainer}>
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}> <Slider
<View style={s.lyrHandle} /> style={{ flex: 1, height: 40 }}
</Pressable> minimumValue={0}
<View style={s.lyrHeader}> maximumValue={1}
{cover value={pct}
? <Image source={{ uri: cover }} style={s.lyrThumb} contentFit="cover" /> minimumTrackTintColor={Colors.accent}
: <View style={[s.lyrThumb, { backgroundColor: Colors.elevated }]} />} maximumTrackTintColor="rgba(255,255,255,0.15)"
<View style={{ flex: 1, minWidth: 0 }}> thumbTintColor={Colors.text}
<Text style={s.lyrTrackTitle} numberOfLines={1}>{track.title}</Text> onSlidingComplete={(v) => seekTo(v * duration)}
<Text style={s.lyrArtist} numberOfLines={1}>{track.artist}</Text> />
</View> </View>
</View> <View style={s.timeRow}>
<ScrollView ref={lyricsScrollRef} style={s.lyrScroll} contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}> <Text style={s.timeText}>{fmt(progress)}</Text>
{lrcLines.length > 0 <Text style={s.timeText}>{fmt(duration - progress)}</Text>
? lrcLines.map((line, i) => ( </View>
<Text key={i} style={[s.lyrLine, i === currentLineIdx && s.lyrLineActive]} onLayout={(e) => { lineHeights.current[i] = e.nativeEvent.layout.height; }}> </View>
{line.text}
</Text> {/* Controls with better styling */}
)) <View style={s.controls}>
: plainLyrics <CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
? <Text style={s.plainLyrics}>{plainLyrics}</Text> <ShuffleIcon active={shuffle} />
: <Text style={s.lyrEmpty}>Текст не найден</Text>} </CtlBtn>
</ScrollView> <CtlBtn onPress={onPrev}>
</BlurView> <Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
</View> <Rect x={5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
)} <Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} />
</Animated.View> </Svg>
); </CtlBtn>
} <Pressable style={s.playBtn} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}>
{playing
function CtlBtn({ onPress, active = false, children }: { onPress: () => void; active?: boolean; children: React.ReactNode }) { ? <Svg width={28} height={28} viewBox="0 0 24 24"><Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /><Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /></Svg>
const sc = useSharedValue(1); : <Svg width={28} height={28} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" /></Svg>}
const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] })); </Pressable>
return ( <CtlBtn onPress={onNext}>
<Animated.View style={st}> <Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
<Pressable onPress={onPress} onPressIn={() => { sc.value = withSpring(0.82, { damping: 12 }); }} onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} hitSlop={12} style={[s.ctlBtn, active && s.ctlBtnActive]}> <Rect x={20.5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
{children} <Path d="M6 6l12.5 8L6 22V6z" fill={Colors.text} />
</Pressable> </Svg>
</Animated.View> </CtlBtn>
); <CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
} <RepeatIcon repeat={repeat} />
</CtlBtn>
function ShuffleIcon({ active }: { active: boolean }) { </View>
const c = active ? Colors.accent : 'rgba(255,255,255,0.5)';
return ( {/* Lyrics button */}
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none"> <View style={s.bottomRow}>
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> <Pressable style={[s.lyrBtn, lyricsOpen && s.lyrBtnActive]} onPress={loadLyrics}>
<Path d="M4 20L21 3" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> <Svg width={15} height={15} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.5)'} strokeWidth={2} viewBox="0 0 24 24">
<Polyline points="21,16 21,21 16,21" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> <Line x1={3} y1={8} x2={21} y2={8} strokeLinecap="round" /><Line x1={3} y1={12} x2={21} y2={12} strokeLinecap="round" /><Line x1={3} y1={16} x2={15} y2={16} strokeLinecap="round" />
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> </Svg>
</Svg> <Text style={[s.lyrBtnText, lyricsOpen && { color: Colors.accent }]}>
); {lyricsLoading ? 'Загрузка…' : 'Текст'}
} </Text>
</Pressable>
function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) { </View>
const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.5)';
return ( </Animated.View>
<View> </PanGestureHandler>
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> {/* Lyrics sheet with better design */}
<Path d="M3 11V9a4 4 0 0 1 4-4h14" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> {lyricsOpen && (
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> <View style={s.lyrSheet}>
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> <Pressable style={StyleSheet.absoluteFill} onPress={() => setLyricsOpen(false)} />
</Svg> <BlurView intensity={50} tint="dark" style={s.lyrInner}>
{repeat === 2 && <View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>} <Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
</View> <View style={s.lyrHandle} />
); </Pressable>
} <View style={s.lyrHeader}>
{cover
function fmt(sec: number) { ? <Image source={{ uri: cover }} style={s.lyrThumb} contentFit="cover" />
const m = Math.floor(sec / 60), s = Math.floor(sec % 60); : <View style={[s.lyrThumb, { backgroundColor: Colors.elevated }]} />}
return `${m}:${s < 10 ? '0' : ''}${s}`; <View style={{ flex: 1, minWidth: 0 }}>
} <Text style={s.lyrTrackTitle} numberOfLines={1}>{track.title}</Text>
<Text style={s.lyrArtist} numberOfLines={1}>{track.artist}</Text>
const s = StyleSheet.create({ </View>
root: { zIndex: 999 }, </View>
bgDim: { backgroundColor: 'rgba(0,0,0,0.65)' }, <ScrollView ref={lyricsScrollRef} style={s.lyrScroll} contentContainerStyle={{ paddingVertical: 40, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false} scrollEventThrottle={16}>
sheet: { flex: 1, paddingHorizontal: 24 }, {lrcLines.length > 0
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 8 }, ? lrcLines.map((line, i) => (
topLabel: { color: 'rgba(255,255,255,0.38)', fontSize: 11, fontWeight: '600', letterSpacing: 1.2, textTransform: 'uppercase' }, <Animated.Text
closeBtn: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.1)', alignItems: 'center', justifyContent: 'center' }, key={i}
coverWrap: { alignItems: 'center', marginVertical: 20 }, style={[
coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.7, shadowRadius: 32 }, s.lyrLine,
cover: { width: COVER, height: COVER, borderRadius: Radius.xl, backgroundColor: Colors.elevated }, i === currentLineIdx && s.lyrLineActive,
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 12 }, i < currentLineIdx && s.lyrLinePassed
metaText: { flex: 1 }, ]}
trackTitle: { color: Colors.text, fontSize: 22, fontWeight: '700', letterSpacing: -0.3 }, onLayout={(e) => { lineHeights.current[i] = e.nativeEvent.layout.height; }}
artist: { color: 'rgba(255,255,255,0.55)', fontSize: 17, marginTop: 4 }, >
seekWrap: { marginBottom: 8 }, {line.text}
timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 }, </Animated.Text>
timeText: { color: 'rgba(255,255,255,0.35)', fontSize: Font.xs }, ))
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 20 }, : plainLyrics
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full }, ? <Text style={s.plainLyrics}>{plainLyrics}</Text>
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' }, : <Text style={s.lyrEmpty}>Текст не найден</Text>}
playBtn: { width: 60, height: 60, borderRadius: 30, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center' }, </ScrollView>
bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }, </BlurView>
lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 14, paddingVertical: 8, borderRadius: Radius.full }, </View>
lyrBtnText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '600' }, )}
repBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center' }, </Animated.View>
repBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' }, );
lyrSheet: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' }, }
lyrInner: { height: '70%', borderTopLeftRadius: 20, borderTopRightRadius: 20, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.1)' }, <Text style={s.timeText}>{fmt(progress)}</Text>
lyrHandleBar: { alignItems: 'center', paddingTop: 12, paddingBottom: 4 }, <Text style={s.timeText}>{fmt(duration)}</Text>
lyrHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.25)' }, </View>
lyrHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 1, borderColor: 'rgba(255,255,255,0.06)' }, </View>
lyrThumb: { width: 40, height: 40, borderRadius: 8, flexShrink: 0 },
lyrTrackTitle: { fontSize: 13, fontWeight: '600', color: '#fff' }, {/* Controls */}
lyrArtist: { fontSize: 11, color: 'rgba(255,255,255,0.55)' }, <View style={s.controls}>
lyrScroll: { flex: 1 }, <CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
lyrLine: { fontSize: 20, lineHeight: 32, color: 'rgba(255,255,255,0.28)', fontWeight: '600', marginBottom: 8, textAlign: 'center' }, <ShuffleIcon active={shuffle} />
lyrLineActive: { fontSize: 28, lineHeight: 42, color: '#fff', marginBottom: 10 }, </CtlBtn>
plainLyrics: { fontSize: 15, lineHeight: 26, color: 'rgba(255,255,255,0.75)', textAlign: 'center' }, <CtlBtn onPress={onPrev}>
lyrEmpty: { color: 'rgba(255,255,255,0.35)', fontSize: 14, textAlign: 'center', marginTop: 40 }, <Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
}); <Rect x={5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
<Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} />
</Svg>
</CtlBtn>
<Pressable style={s.playBtn} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}>
{playing
? <Svg width={26} height={26} viewBox="0 0 24 24"><Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /><Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /></Svg>
: <Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" /></Svg>}
</Pressable>
<CtlBtn onPress={onNext}>
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
<Rect x={20.5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
<Path d="M6 6l12.5 8L6 22V6z" fill={Colors.text} />
</Svg>
</CtlBtn>
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
<RepeatIcon repeat={repeat} />
</CtlBtn>
</View>
{/* Lyrics button */}
<View style={s.bottomRow}>
<Pressable style={s.lyrBtn} onPress={loadLyrics}>
<Svg width={14} height={14} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.4)'} strokeWidth={2} viewBox="0 0 24 24">
<Line x1={3} y1={8} x2={21} y2={8} /><Line x1={3} y1={12} x2={21} y2={12} /><Line x1={3} y1={16} x2={15} y2={16} />
</Svg>
<Text style={[s.lyrBtnText, lyricsOpen && { color: Colors.accent }]}>
{lyricsLoading ? 'Загрузка…' : 'Текст'}
</Text>
</Pressable>
</View>
</Animated.View>
</PanGestureHandler>
{/* Lyrics sheet */}
{lyricsOpen && (
<View style={s.lyrSheet}>
<Pressable style={StyleSheet.absoluteFill} onPress={() => setLyricsOpen(false)} />
<BlurView intensity={40} tint="dark" style={s.lyrInner}>
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
<View style={s.lyrHandle} />
</Pressable>
<View style={s.lyrHeader}>
{cover
? <Image source={{ uri: cover }} style={s.lyrThumb} contentFit="cover" />
: <View style={[s.lyrThumb, { backgroundColor: Colors.elevated }]} />}
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={s.lyrTrackTitle} numberOfLines={1}>{track.title}</Text>
<Text style={s.lyrArtist} numberOfLines={1}>{track.artist}</Text>
</View>
</View>
<ScrollView ref={lyricsScrollRef} style={s.lyrScroll} contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
{lrcLines.length > 0
? lrcLines.map((line, i) => (
<Text key={i} style={[s.lyrLine, i === currentLineIdx && s.lyrLineActive]} onLayout={(e) => { lineHeights.current[i] = e.nativeEvent.layout.height; }}>
{line.text}
</Text>
))
: plainLyrics
? <Text style={s.plainLyrics}>{plainLyrics}</Text>
: <Text style={s.lyrEmpty}>Текст не найден</Text>}
</ScrollView>
</BlurView>
</View>
)}
</Animated.View>
);
}
function CtlBtn({ onPress, active = false, children }: { onPress: () => void; active?: boolean; children: React.ReactNode }) {
const sc = useSharedValue(1);
const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] }));
return (
<Animated.View style={st}>
<Pressable
onPress={onPress}
onPressIn={() => { sc.value = withSpring(0.75, { damping: 10, mass: 0.8 }); }}
onPressOut={() => { sc.value = withSpring(1, { damping: 10, mass: 0.8 }); }}
hitSlop={14}
style={[s.ctlBtn, active && s.ctlBtnActive]}
>
{children}
</Pressable>
</Animated.View>
);
}
function ShuffleIcon({ active }: { active: boolean }) {
const c = active ? Colors.accent : 'rgba(255,255,255,0.6)';
return (
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M4 20L21 3" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Polyline points="21,16 21,21 16,21" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
}
function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.6)';
return (
<View>
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M3 11V9a4 4 0 0 1 4-4h14" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
{repeat === 2 && <View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>}
</View>
);
}
function fmt(sec: number) {
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
}
const s = StyleSheet.create({
root: { zIndex: 999 },
bgDim: { backgroundColor: 'rgba(0,0,0,0.7)' },
sheet: { flex: 1, paddingHorizontal: 24 },
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 10, marginBottom: 8 },
topLabel: { color: 'rgba(255,255,255,0.4)', fontSize: 11, fontWeight: '700', letterSpacing: 1.3, textTransform: 'uppercase' },
closeBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.12)', alignItems: 'center', justifyContent: 'center' },
coverWrap: { alignItems: 'center', marginVertical: 24, marginBottom: 32 },
coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 28 }, shadowOpacity: 0.8, shadowRadius: 40 },
cover: { width: COVER, height: COVER, borderRadius: Radius.xl, backgroundColor: Colors.elevated, overflow: 'hidden' },
metaRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 16, marginBottom: 16 },
metaText: { flex: 1 },
trackTitle: { color: Colors.text, fontSize: 24, fontWeight: '800', letterSpacing: -0.5, lineHeight: 30 },
artist: { color: 'rgba(255,255,255,0.6)', fontSize: 16, marginTop: 6, fontWeight: '500' },
seekWrap: { marginBottom: 12 },
sliderContainer: { height: 40, justifyContent: 'center' },
timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 2 },
timeText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '500' },
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 6, marginBottom: 24, marginTop: 16 },
ctlBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', borderRadius: 24 },
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.08)' },
playBtn: { width: 64, height: 64, borderRadius: 32, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center', shadowColor: Colors.accent, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 12 },
bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.1)', paddingHorizontal: 16, paddingVertical: 10, borderRadius: Radius.full, borderWidth: 1, borderColor: 'rgba(255,255,255,0.05)' },
lyrBtnActive: { backgroundColor: `${Colors.accent}15`, borderColor: `${Colors.accent}40` },
lyrBtnText: { color: 'rgba(255,255,255,0.5)', fontSize: Font.sm, fontWeight: '600' },
repBadge: { position: 'absolute', top: -4, right: -4, width: 12, height: 12, borderRadius: 6, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center', shadowColor: Colors.accent, shadowOpacity: 0.5, shadowRadius: 4 },
repBadgeText: { color: '#000', fontSize: 6, fontWeight: '900' },
// Lyrics sheet
lyrSheet: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' },
lyrInner: { height: '75%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.08)' },
lyrHandleBar: { alignItems: 'center', paddingTop: 14, paddingBottom: 8 },
lyrHandle: { width: 40, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.28)' },
lyrHeader: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingHorizontal: 20, paddingVertical: 14, borderBottomWidth: 1, borderColor: 'rgba(255,255,255,0.06)' },
lyrThumb: { width: 44, height: 44, borderRadius: 10, flexShrink: 0, shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 6 },
lyrTrackTitle: { fontSize: 14, fontWeight: '700', color: '#fff' },
lyrArtist: { fontSize: 12, color: 'rgba(255,255,255,0.6)', marginTop: 2 },
lyrScroll: { flex: 1 },
lyrLine: { fontSize: 18, lineHeight: 30, color: 'rgba(255,255,255,0.25)', fontWeight: '600', marginBottom: 12, textAlign: 'center' },
lyrLineActive: { fontSize: 32, lineHeight: 44, color: Colors.accent, marginBottom: 16, fontWeight: '800' },
lyrLinePassed: { color: 'rgba(255,255,255,0.35)' },
plainLyrics: { fontSize: 16, lineHeight: 28, color: 'rgba(255,255,255,0.75)', textAlign: 'center', fontWeight: '500' },
lyrEmpty: { color: 'rgba(255,255,255,0.38)', fontSize: 15, textAlign: 'center', marginTop: 60, fontWeight: '500' },
});

View file

@ -1,48 +1,48 @@
export const Colors = { export const Colors = {
bg: '#0a0a0a', bg: '#0a0a0a',
surface: '#111111', surface: '#111111',
elevated: '#1a1a1a', elevated: '#1a1a1a',
subtle: '#2a2a2a', subtle: '#2a2a2a',
border: 'rgba(255,255,255,0.07)', border: 'rgba(255,255,255,0.07)',
border2: 'rgba(255,255,255,0.14)', border2: 'rgba(255,255,255,0.14)',
accent: '#4F86F5', accent: '#4F86F5',
accentDim:'rgba(79,134,245,0.18)', accentDim:'rgba(79,134,245,0.18)',
pink: '#e84393', pink: '#e84393',
text: '#f0f0f0', text: '#f0f0f0',
text2: '#888888', text2: '#888888',
text3: '#444444', text3: '#444444',
red: '#e84040', red: '#e84040',
white: '#ffffff', white: '#ffffff',
// legacy alias // legacy alias
green: '#4F86F5', green: '#4F86F5',
}; };
export const Spacing = { export const Spacing = {
xs: 4, xs: 4,
sm: 8, sm: 8,
md: 16, md: 16,
lg: 24, lg: 24,
xl: 32, xl: 32,
xxl: 48, xxl: 48,
}; };
export const Radius = { export const Radius = {
sm: 8, sm: 8,
md: 10, md: 10,
lg: 14, lg: 14,
xl: 16, xl: 16,
full: 9999, full: 9999,
}; };
export const Font = { export const Font = {
xs: 11, xs: 11,
sm: 13, sm: 13,
md: 14, md: 14,
lg: 17, lg: 17,
xl: 20, xl: 20,
xxl: 26, xxl: 26,
xxxl: 32, xxxl: 32,
}; };
export const TAB_BAR_HEIGHT = 88; export const TAB_BAR_HEIGHT = 88;
export const MINI_PLAYER_HEIGHT = 64; export const MINI_PLAYER_HEIGHT = 64;

View file

@ -1,26 +1,26 @@
{ {
"cli": { "cli": {
"version": ">= 10.0.0", "version": ">= 10.0.0",
"appVersionSource": "local" "appVersionSource": "local"
}, },
"build": { "build": {
"development": { "development": {
"developmentClient": true, "developmentClient": true,
"distribution": "internal" "distribution": "internal"
}, },
"preview": { "preview": {
"distribution": "internal", "distribution": "internal",
"ios": { "ios": {
"simulator": false "simulator": false
} }
}, },
"production": { "production": {
"ios": { "ios": {
"buildConfiguration": "Release" "buildConfiguration": "Release"
} }
} }
}, },
"submit": { "submit": {
"production": {} "production": {}
} }
} }

View file

@ -1,55 +1,55 @@
import TrackPlayer, { Event } from 'react-native-track-player'; import TrackPlayer, { Event } from 'react-native-track-player';
import { usePlayer } from '../store'; import { usePlayer } from '../store';
import { loadAndPlay } from './useAudio'; import { loadAndPlay } from './useAudio';
export async function PlaybackService() { export async function PlaybackService() {
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play()); TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause()); TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
TrackPlayer.addEventListener(Event.RemoteNext, async () => { TrackPlayer.addEventListener(Event.RemoteNext, async () => {
const state = usePlayer.getState(); const state = usePlayer.getState();
const ni = state.nextTrack(); const ni = state.nextTrack();
state.setIndex(ni); state.setIndex(ni);
const track = state.queue[ni]; const track = state.queue[ni];
if (track) await loadAndPlay(track).catch(console.error); if (track) await loadAndPlay(track).catch(console.error);
}); });
TrackPlayer.addEventListener(Event.RemotePrevious, async () => { TrackPlayer.addEventListener(Event.RemotePrevious, async () => {
const state = usePlayer.getState(); const state = usePlayer.getState();
const progress = await TrackPlayer.getProgress(); const progress = await TrackPlayer.getProgress();
if (progress.position > 3) { if (progress.position > 3) {
await TrackPlayer.seekTo(0); await TrackPlayer.seekTo(0);
return; return;
} }
const pi = state.prevTrack(); const pi = state.prevTrack();
state.setIndex(pi); state.setIndex(pi);
const track = state.queue[pi]; const track = state.queue[pi];
if (track) await loadAndPlay(track).catch(console.error); if (track) await loadAndPlay(track).catch(console.error);
}); });
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) =>
TrackPlayer.seekTo(position), TrackPlayer.seekTo(position),
); );
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => { TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => {
const state = usePlayer.getState(); const state = usePlayer.getState();
const prevIdx = state.index; const prevIdx = state.index;
if (state.repeat === 2) { if (state.repeat === 2) {
await TrackPlayer.seekTo(0); await TrackPlayer.seekTo(0);
await TrackPlayer.play(); await TrackPlayer.play();
return; return;
} }
const ni = state.nextTrack(); const ni = state.nextTrack();
state.setIndex(ni); state.setIndex(ni);
if (ni === prevIdx) { if (ni === prevIdx) {
state.setPlaying(false); state.setPlaying(false);
return; return;
} }
const track = state.queue[ni]; const track = state.queue[ni];
if (track) await loadAndPlay(track).catch(console.error); if (track) await loadAndPlay(track).catch(console.error);
}); });
} }

View file

@ -1,106 +1,108 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import TrackPlayer, { import TrackPlayer, {
State, State,
Capability, Capability,
AppKilledPlaybackBehavior, AppKilledPlaybackBehavior,
usePlaybackState, usePlaybackState,
useProgress, useProgress,
} from 'react-native-track-player'; } from 'react-native-track-player';
import { usePlayer } from '../store'; import { usePlayer } from '../store';
import { coverUrl, Track } from '../services/api'; import { coverUrl, Track, getTrackUrl } from '../services/api';
let _setupDone = false; let _setupDone = false;
let _lastLoadedId: number | null = null; let _lastLoadedId: number | null = null;
async function ensureSetup() { async function ensureSetup() {
if (_setupDone) return; if (_setupDone) return;
_setupDone = true; _setupDone = true;
try { try {
await TrackPlayer.setupPlayer({ minBuffer: 15, maxBuffer: 50, playBuffer: 2.5 }); await TrackPlayer.setupPlayer({ minBuffer: 15, maxBuffer: 50, playBuffer: 2.5 });
await TrackPlayer.updateOptions({ await TrackPlayer.updateOptions({
android: { android: {
appKilledPlaybackBehavior: appKilledPlaybackBehavior:
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification, AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
}, },
capabilities: [ capabilities: [
Capability.Play, Capability.Play,
Capability.Pause, Capability.Pause,
Capability.SkipToNext, Capability.SkipToNext,
Capability.SkipToPrevious, Capability.SkipToPrevious,
Capability.SeekTo, Capability.SeekTo,
], ],
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext], compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
}); });
} catch (e: any) { } catch (e: any) {
// Player already set up on hot reload — update options only // Player already set up on hot reload — update options only
if (e?.message?.toLowerCase().includes('already')) { if (e?.message?.toLowerCase().includes('already')) {
_setupDone = true; _setupDone = true;
return; return;
} }
_setupDone = false; _setupDone = false;
throw e; throw e;
} }
} }
// Load a track and start playing. Mutex prevents double-load on the same ID. // Load a track and start playing. Mutex prevents double-load on the same ID.
export async function loadAndPlay(track: Track) { // Uses local file if downloaded, otherwise streams from CDN
if (_lastLoadedId === track.id) return; export async function loadAndPlay(track: Track) {
_lastLoadedId = track.id; if (_lastLoadedId === track.id) return;
try { _lastLoadedId = track.id;
await TrackPlayer.reset(); try {
await TrackPlayer.add({ const url = await getTrackUrl(track);
id: String(track.id), await TrackPlayer.reset();
url: track.cdn_url2 || track.cdn_url, await TrackPlayer.add({
title: track.title, id: String(track.id),
artist: track.artist, url: url || (track.cdn_url2 || track.cdn_url),
artwork: coverUrl(track.cover_url) ?? undefined, title: track.title,
duration: track.duration, artist: track.artist,
}); artwork: coverUrl(track.cover_url) ?? undefined,
await TrackPlayer.play(); duration: track.duration,
} catch (e) { });
_lastLoadedId = null; await TrackPlayer.play();
throw e; } catch (e) {
} _lastLoadedId = null;
} throw e;
}
export async function togglePlay() { }
const { state } = await TrackPlayer.getPlaybackState();
if (state === State.Playing) await TrackPlayer.pause(); export async function togglePlay() {
else await TrackPlayer.play(); const { state } = await TrackPlayer.getPlaybackState();
} if (state === State.Playing) await TrackPlayer.pause();
else await TrackPlayer.play();
export async function seekTo(seconds: number) { }
await TrackPlayer.seekTo(seconds);
} export async function seekTo(seconds: number) {
await TrackPlayer.seekTo(seconds);
// Call ONCE from the root layout only }
export function useAudio() {
const { queue, index, setPlaying, setProgress, setDuration } = usePlayer(); // Call ONCE from the root layout only
const currentTrack = queue[index] ?? null; export function useAudio() {
const playbackState = usePlaybackState(); const { queue, index, setPlaying, setProgress, setDuration } = usePlayer();
const progress = useProgress(500); const currentTrack = queue[index] ?? null;
const playbackState = usePlaybackState();
// One-time player setup const progress = useProgress(500);
useEffect(() => {
ensureSetup().catch(console.error); // One-time player setup
}, []); useEffect(() => {
ensureSetup().catch(console.error);
// Sync TrackPlayer progress → store }, []);
useEffect(() => {
setProgress(progress.position); // Sync TrackPlayer progress → store
setDuration(progress.duration); useEffect(() => {
}, [progress.position, progress.duration]); setProgress(progress.position);
setDuration(progress.duration);
// Sync TrackPlayer playing state → store }, [progress.position, progress.duration]);
useEffect(() => {
setPlaying(playbackState.state === State.Playing); // Sync TrackPlayer playing state → store
}, [playbackState.state]); useEffect(() => {
setPlaying(playbackState.state === State.Playing);
// Load new track when queue index changes }, [playbackState.state]);
useEffect(() => {
if (!currentTrack) return; // Load new track when queue index changes
loadAndPlay(currentTrack).catch(console.error); useEffect(() => {
}, [currentTrack?.id]); if (!currentTrack) return;
loadAndPlay(currentTrack).catch(console.error);
return { togglePlay, seek: seekTo }; }, [currentTrack?.id]);
}
return { togglePlay, seek: seekTo };
}

View file

@ -1,58 +1,58 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { getAccessToken, getMe, clearTokens, User } from '../services/api'; import { getAccessToken, getMe, clearTokens, User } from '../services/api';
import { useAuth } from '../store'; import { useAuth } from '../store';
const USER_KEY = 'ns-music-user'; const USER_KEY = 'ns-music-user';
export async function saveUser(user: User): Promise<void> { export async function saveUser(user: User): Promise<void> {
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user)); await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
} }
export async function loadCachedUser(): Promise<User | null> { export async function loadCachedUser(): Promise<User | null> {
try { try {
const raw = await AsyncStorage.getItem(USER_KEY); const raw = await AsyncStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null; return raw ? JSON.parse(raw) : null;
} catch { return null; } } catch { return null; }
} }
export function useBootAuth() { export function useBootAuth() {
const { setUser, setReady } = useAuth(); const { setUser, setReady } = useAuth();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const token = await getAccessToken(); const token = await getAccessToken();
if (!token) { setReady(true); return; } if (!token) { setReady(true); return; }
// Use cached user for immediate display while validating // Use cached user for immediate display while validating
const cached = await loadCachedUser(); const cached = await loadCachedUser();
if (cached) setUser(cached); if (cached) setUser(cached);
// Validate token and get fresh data // Validate token and get fresh data
const me = await getMe(); const me = await getMe();
if (me) { if (me) {
// Preserve plan from initial login since no endpoint exposes it // Preserve plan from initial login since no endpoint exposes it
const merged: User = { ...me, plan: cached?.plan || me.plan }; const merged: User = { ...me, plan: cached?.plan || me.plan };
setUser(merged); setUser(merged);
saveUser(merged).catch(() => {}); saveUser(merged).catch(() => {});
} else { } else {
// Token invalid — clear everything // Token invalid — clear everything
await clearTokens(); await clearTokens();
await AsyncStorage.removeItem(USER_KEY); await AsyncStorage.removeItem(USER_KEY);
setUser(null); setUser(null);
} }
} catch { } catch {
// Network error — cached user already set above, just mark ready // Network error — cached user already set above, just mark ready
} finally { } finally {
setReady(true); setReady(true);
} }
})(); })();
}, []); }, []);
} }
export async function logout() { export async function logout() {
await clearTokens(); await clearTokens();
await AsyncStorage.removeItem(USER_KEY).catch(() => {}); await AsyncStorage.removeItem(USER_KEY).catch(() => {});
useAuth.getState().setUser(null); useAuth.getState().setUser(null);
} }

View file

@ -1,5 +1,5 @@
import 'expo-router/entry'; import 'expo-router/entry';
import TrackPlayer from 'react-native-track-player'; import TrackPlayer from 'react-native-track-player';
import { PlaybackService } from './hooks/PlaybackService'; import { PlaybackService } from './hooks/PlaybackService';
TrackPlayer.registerPlaybackService(() => PlaybackService); TrackPlayer.registerPlaybackService(() => PlaybackService);

112
main.yml
View file

@ -1,56 +1,56 @@
name: Build iOS IPA name: Build iOS IPA
on: on:
push: push:
branches: [main, master] branches: [main, master]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node 20 - name: Setup Node 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Expo prebuild - name: Expo prebuild
run: npx expo prebuild --platform ios --clean run: npx expo prebuild --platform ios --clean
env: env:
EXPO_NO_DOTENV: 1 EXPO_NO_DOTENV: 1
- name: Build unsigned archive - name: Build unsigned archive
run: | run: |
xcodebuild archive \ xcodebuild archive \
-workspace "ios/Burmalda Music.xcworkspace" \ -workspace "ios/Burmalda Music.xcworkspace" \
-scheme "Burmalda Music" \ -scheme "Burmalda Music" \
-configuration Release \ -configuration Release \
-sdk iphoneos \ -sdk iphoneos \
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \ -archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \ CODE_SIGN_IDENTITY="" \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
ENABLE_BITCODE=NO \ ENABLE_BITCODE=NO \
| xcpretty || true | xcpretty || true
- name: Package IPA - name: Package IPA
run: | run: |
mkdir -p Payload mkdir -p Payload
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/ cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/
zip -qr burmalda-unsigned.ipa Payload zip -qr burmalda-unsigned.ipa Payload
rm -rf Payload rm -rf Payload
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: burmalda-unsigned-ipa name: burmalda-unsigned-ipa
path: burmalda-unsigned.ipa path: burmalda-unsigned.ipa
retention-days: 30 retention-days: 30

View file

@ -1,4 +1,4 @@
const { getDefaultConfig } = require('expo/metro-config'); const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname); const config = getDefaultConfig(__dirname);
module.exports = config; module.exports = config;

View file

@ -1,42 +1,44 @@
{ {
"name": "burmalda-music", "name": "burmalda-music",
"version": "1.0.0", "version": "1.0.0",
"main": "./index.js", "main": "./index.js",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"ios": "expo run:ios", "ios": "expo run:ios",
"build:ios": "eas build --platform ios --profile production", "build:ios": "eas build --platform ios --profile production",
"build:preview": "eas build --platform ios --profile preview", "build:preview": "eas build --platform ios --profile preview",
"update": "eas update --branch production" "update": "eas update --branch production"
}, },
"dependencies": { "dependencies": {
"expo": "~51.0.28", "expo": "~51.0.28",
"expo-router": "~3.5.23", "expo-router": "~3.5.23",
"expo-blur": "~13.0.2", "expo-blur": "~13.0.2",
"expo-constants": "~16.0.2", "expo-constants": "~16.0.2",
"expo-haptics": "~13.0.1", "expo-haptics": "~13.0.1",
"expo-image": "~1.12.15", "expo-image": "~1.12.15",
"expo-linear-gradient": "~13.0.2", "expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
"expo-secure-store": "~13.0.2", "expo-secure-store": "~13.0.2",
"expo-status-bar": "~1.12.1", "expo-status-bar": "~1.12.1",
"expo-updates": "~0.25.27", "expo-updates": "~0.25.27",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"react": "18.2.0", "expo-file-system": "~16.0.9",
"react-native": "0.74.5", "expo-network": "~6.0.2",
"react-native-gesture-handler": "~2.16.2", "react": "18.2.0",
"react-native-reanimated": "~3.10.1", "react-native": "0.74.5",
"react-native-safe-area-context": "4.10.5", "react-native-gesture-handler": "~2.16.2",
"react-native-screens": "3.31.1", "react-native-reanimated": "~3.10.1",
"zustand": "^4.5.4", "react-native-safe-area-context": "4.10.5",
"@react-native-async-storage/async-storage": "1.23.1", "react-native-screens": "3.31.1",
"@react-native-community/slider": "4.5.2", "zustand": "^4.5.4",
"react-native-track-player": "^4.1.1" "@react-native-async-storage/async-storage": "1.23.1",
}, "@react-native-community/slider": "4.5.2",
"devDependencies": { "react-native-track-player": "^4.1.1"
"@babel/core": "^7.24.0", },
"@types/react": "~18.2.79", "devDependencies": {
"@types/react-native": "~0.73.0", "@babel/core": "^7.24.0",
"typescript": "~5.3.3" "@types/react": "~18.2.79",
} "@types/react-native": "~0.73.0",
} "typescript": "~5.3.3"
}
}

View file

@ -1,178 +1,195 @@
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
const BASE = 'https://music.nodeshift.space'; const BASE = 'https://music.nodeshift.space';
const CLOUD = 'https://cloud.nodeshift.space'; const CLOUD = 'https://cloud.nodeshift.space';
const CLIENT_ID = 'nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U'; const CLIENT_ID = 'nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U';
const REDIRECT = 'burmalda://auth/callback'; const REDIRECT = 'burmalda://auth/callback';
// ── Token storage ───────────────────────────────────────────────────────────── // ── Token storage ─────────────────────────────────────────────────────────────
export async function saveTokens(access: string, refresh: string) { export async function saveTokens(access: string, refresh: string) {
await SecureStore.setItemAsync('access_token', access); await SecureStore.setItemAsync('access_token', access);
await SecureStore.setItemAsync('refresh_token', refresh); await SecureStore.setItemAsync('refresh_token', refresh);
} }
export async function getAccessToken(): Promise<string | null> { export async function getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync('access_token'); return SecureStore.getItemAsync('access_token');
} }
export async function clearTokens() { export async function clearTokens() {
await SecureStore.deleteItemAsync('access_token'); await SecureStore.deleteItemAsync('access_token');
await SecureStore.deleteItemAsync('refresh_token'); await SecureStore.deleteItemAsync('refresh_token');
} }
// ── Fetch helper ────────────────────────────────────────────────────────────── // ── Fetch helper ──────────────────────────────────────────────────────────────
export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> { export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
const token = await getAccessToken(); const token = await getAccessToken();
const headers: HeadersInit = { const headers: HeadersInit = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts.headers as Record<string, string> || {}), ...(opts.headers as Record<string, string> || {}),
}; };
return fetch(`${BASE}${path}`, { ...opts, headers }); return fetch(`${BASE}${path}`, { ...opts, headers });
} }
// ── OAuth ───────────────────────────────────────────────────────────────────── // ── OAuth ─────────────────────────────────────────────────────────────────────
export function buildOAuthUrl(state: string): string { export function buildOAuthUrl(state: string): string {
const p = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT, state, scope: 'profile email' }); const p = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT, state, scope: 'profile email' });
return `${CLOUD}/api/cloud/oauth/authorize?${p}`; return `${CLOUD}/api/cloud/oauth/authorize?${p}`;
} }
export async function exchangeCode(code: string): Promise<{ access_token: string; refresh_token: string; user: User }> { export async function exchangeCode(code: string): Promise<{ access_token: string; refresh_token: string; user: User }> {
const res = await fetch(`${BASE}/api/auth/cloud-login`, { const res = await fetch(`${BASE}/api/auth/cloud-login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirect_uri: REDIRECT }), body: JSON.stringify({ code, redirect_uri: REDIRECT }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Auth failed'); throw new Error(err.detail || 'Auth failed');
} }
return res.json(); return res.json();
} }
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
export interface Track { export interface Track {
id: number; id: number;
title: string; title: string;
artist: string; artist: string;
duration: number; duration: number;
cover_url: string | null; cover_url: string | null;
cdn_url: string; cdn_url: string;
cdn_url2: string; cdn_url2: string;
filename: string; filename: string;
album?: string; album?: string;
play_count?: number; play_count?: number;
source?: string; source?: string;
cdn_synced?: boolean; cdn_synced?: boolean;
} }
export interface Album { export interface Album {
id: number; id: number;
title: string; title: string;
artist: string; artist: string;
cover_url: string | null; cover_url: string | null;
track_count: number; track_count: number;
} }
export interface User { export interface User {
id: number; id: number;
email: string; email: string;
username: string | null; username: string | null;
avatar_url: string | null; avatar_url: string | null;
plan: string; plan: string;
} }
export interface Lyrics { export interface Lyrics {
synced: string | null; synced: string | null;
plain: string | null; plain: string | null;
} }
// ── API calls ───────────────────────────────────────────────────────────────── // ── API calls ─────────────────────────────────────────────────────────────────
export async function getTracks(): Promise<Track[]> { export async function getTracks(): Promise<Track[]> {
const r = await apiFetch('/api/music/tracks?limit=200'); const r = await apiFetch('/api/music/tracks?limit=200');
if (!r.ok) return []; if (!r.ok) return [];
return r.json(); return r.json();
} }
export async function getAlbums(): Promise<Album[]> { export async function getAlbums(): Promise<Album[]> {
const r = await apiFetch('/api/music/albums'); const r = await apiFetch('/api/music/albums');
if (!r.ok) return []; if (!r.ok) return [];
const data: Array<{ album: string; artist: string; count: number }> = await r.json(); const data: Array<{ album: string; artist: string; count: number }> = await r.json();
return data.map((a, i) => ({ return data.map((a, i) => ({
id: i, id: i,
title: a.album, title: a.album,
artist: a.artist, artist: a.artist,
cover_url: null, cover_url: null,
track_count: a.count, track_count: a.count,
})); }));
} }
export async function getMe(): Promise<User | null> { export async function getMe(): Promise<User | null> {
// Validate token // Validate token
const r = await apiFetch('/api/auth/me'); const r = await apiFetch('/api/auth/me');
if (!r.ok) return null; if (!r.ok) return null;
const auth = await r.json(); const auth = await r.json();
// Get music profile for username / avatar // Get music profile for username / avatar
const pr = await apiFetch('/api/music/profile/me'); const pr = await apiFetch('/api/music/profile/me');
const profile = pr.ok ? await pr.json() : {}; const profile = pr.ok ? await pr.json() : {};
return { return {
id: auth.id, id: auth.id,
email: auth.email, email: auth.email,
username: profile.username || null, username: profile.username || null,
avatar_url: profile.avatar_url || null, avatar_url: profile.avatar_url || null,
plan: 'free', // merged with cached plan in useBootAuth plan: 'free', // merged with cached plan in useBootAuth
}; };
} }
export async function searchTracks(q: string): Promise<Track[]> { export async function searchTracks(q: string): Promise<Track[]> {
const r = await apiFetch('/api/music/tracks?limit=200'); const r = await apiFetch('/api/music/tracks?limit=200');
if (!r.ok) return []; if (!r.ok) return [];
const all: Track[] = await r.json(); const all: Track[] = await r.json();
if (!q.trim()) return all; if (!q.trim()) return all;
const lq = q.toLowerCase(); const lq = q.toLowerCase();
return all.filter(t => return all.filter(t =>
t.title.toLowerCase().includes(lq) || t.title.toLowerCase().includes(lq) ||
t.artist.toLowerCase().includes(lq) || t.artist.toLowerCase().includes(lq) ||
(t.album && t.album.toLowerCase().includes(lq)) (t.album && t.album.toLowerCase().includes(lq))
); );
} }
export async function fetchLyrics(id: number): Promise<Lyrics | null> { export async function fetchLyrics(id: number): Promise<Lyrics | null> {
const r = await apiFetch(`/api/music/lyrics/${id}`); const r = await apiFetch(`/api/music/lyrics/${id}`);
if (!r.ok) return null; if (!r.ok) return null;
return r.json(); return r.json();
} }
// cover_url from the server is one of: // cover_url from the server is one of:
// - "https://cdn.nodeshift.space/music/xxx.jpg" (full URL) // - "https://cdn.nodeshift.space/music/xxx.jpg" (full URL)
// - "/api/music/cover/xxx.jpg" (relative path) // - "/api/music/cover/xxx.jpg" (relative path)
// - "xxx.jpg" (bare filename, legacy) // - "xxx.jpg" (bare filename, legacy)
export function coverUrl(raw: string | null): string | null { export function coverUrl(raw: string | null): string | null {
if (!raw) return null; if (!raw) return null;
if (raw.startsWith('http')) return raw; if (raw.startsWith('http')) return raw;
if (raw.startsWith('/')) return `${BASE}${raw}`; if (raw.startsWith('/')) return `${BASE}${raw}`;
return `${BASE}/api/music/cover/${raw}`; return `${BASE}/api/music/cover/${raw}`;
} }
// ── LRC parser ──────────────────────────────────────────────────────────────── // ── Offline/Local playback ────────────────────────────────────────────────────
export interface LrcLine { export async function getTrackUrl(track: Track): Promise<string> {
time: number; try {
text: string; const { isTrackDownloaded, getTrackFilePath } = await import('./offline');
} const isDownloaded = await isTrackDownloaded(track.id);
if (isDownloaded) {
export function parseLrc(lrc: string): LrcLine[] { return getTrackFilePath(track.id);
const lines: LrcLine[] = []; }
for (const raw of lrc.split('\n')) { } catch (err) {
const m = raw.match(/^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$/); console.warn('Failed to check downloaded track:', err);
if (!m) continue; }
const time = parseInt(m[1]) * 60 + parseFloat(`${m[2]}.${m[3]}`);
if (m[4].trim()) lines.push({ time, text: m[4].trim() }); // Fallback to CDN URL
} return track.cdn_url || track.cdn_url2 || '';
return lines.sort((a, b) => a.time - b.time); }
}
// ── LRC parser ────────────────────────────────────────────────────────────────
export interface LrcLine {
time: number;
text: string;
}
export function parseLrc(lrc: string): LrcLine[] {
const lines: LrcLine[] = [];
for (const raw of lrc.split('\n')) {
const m = raw.match(/^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$/);
if (!m) continue;
const time = parseInt(m[1]) * 60 + parseFloat(`${m[2]}.${m[3]}`);
if (m[4].trim()) lines.push({ time, text: m[4].trim() });
}
return lines.sort((a, b) => a.time - b.time);
}

193
services/offline.ts Normal file
View file

@ -0,0 +1,193 @@
import * as FileSystem from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Track, Album } from './api';
const OFFLINE_TRACKS_KEY = 'offline_tracks_cache';
const OFFLINE_ALBUMS_KEY = 'offline_albums_cache';
const DOWNLOADED_TRACKS_KEY = 'downloaded_tracks';
const DOCUMENTS_DIR = `${FileSystem.documentDirectory}music/`;
// ── Initialization ─────────────────────────────────────────────────────────────
export async function initOfflineStorage() {
try {
const dir = await FileSystem.getInfoAsync(DOCUMENTS_DIR);
if (!dir.exists) {
await FileSystem.makeDirectoryAsync(DOCUMENTS_DIR, { intermediates: true });
}
} catch (err) {
console.warn('Failed to create offline storage dir:', err);
}
}
// ── Cache management ───────────────────────────────────────────────────────────
export async function cacheTracksAndAlbums(tracks: Track[], albums: Album[]) {
try {
await Promise.all([
AsyncStorage.setItem(OFFLINE_TRACKS_KEY, JSON.stringify(tracks)),
AsyncStorage.setItem(OFFLINE_ALBUMS_KEY, JSON.stringify(albums)),
]);
} catch (err) {
console.warn('Failed to cache tracks and albums:', err);
}
}
export async function getCachedTracks(): Promise<Track[] | null> {
try {
const cached = await AsyncStorage.getItem(OFFLINE_TRACKS_KEY);
return cached ? JSON.parse(cached) : null;
} catch (err) {
console.warn('Failed to get cached tracks:', err);
return null;
}
}
export async function getCachedAlbums(): Promise<Album[] | null> {
try {
const cached = await AsyncStorage.getItem(OFFLINE_ALBUMS_KEY);
return cached ? JSON.parse(cached) : null;
} catch (err) {
console.warn('Failed to get cached albums:', err);
return null;
}
}
// ── Downloaded tracks management ───────────────────────────────────────────────
export async function getDownloadedTracks(): Promise<number[]> {
try {
const raw = await AsyncStorage.getItem(DOWNLOADED_TRACKS_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
export async function addDownloadedTrack(trackId: number) {
try {
const ids = await getDownloadedTracks();
if (!ids.includes(trackId)) {
ids.push(trackId);
await AsyncStorage.setItem(DOWNLOADED_TRACKS_KEY, JSON.stringify(ids));
}
} catch (err) {
console.warn('Failed to add downloaded track:', err);
}
}
export async function removeDownloadedTrack(trackId: number) {
try {
let ids = await getDownloadedTracks();
ids = ids.filter(id => id !== trackId);
await AsyncStorage.setItem(DOWNLOADED_TRACKS_KEY, JSON.stringify(ids));
} catch (err) {
console.warn('Failed to remove downloaded track:', err);
}
}
export function getTrackFilePath(trackId: number): string {
return `${DOCUMENTS_DIR}track_${trackId}.mp3`;
}
export async function isTrackDownloaded(trackId: number): Promise<boolean> {
try {
const info = await FileSystem.getInfoAsync(getTrackFilePath(trackId));
return info.exists;
} catch {
return false;
}
}
// ── Track download ───────────────────────────────────────────────────────────
export interface DownloadProgress {
trackId: number;
progress: number;
loaded: number;
total: number;
}
export async function downloadTrack(
track: Track,
onProgress?: (progress: DownloadProgress) => void
): Promise<boolean> {
try {
const filePath = getTrackFilePath(track.id);
const uri = track.cdn_url || track.cdn_url2;
if (!uri) {
console.warn('No download URL for track:', track.id);
return false;
}
// Check if already downloaded
if (await isTrackDownloaded(track.id)) {
await addDownloadedTrack(track.id);
return true;
}
// Download with progress tracking
const downloadResumable = FileSystem.createDownloadResumable(
uri,
filePath,
{},
(progress) => {
const { totalBytesWritten, totalBytesExpectedToDownload } = progress;
onProgress?.({
trackId: track.id,
progress: totalBytesWritten / totalBytesExpectedToDownload,
loaded: totalBytesWritten,
total: totalBytesExpectedToDownload,
});
}
);
const result = await downloadResumable.downloadAsync();
if (result?.uri) {
await addDownloadedTrack(track.id);
return true;
}
return false;
} catch (err) {
console.warn('Failed to download track:', err);
return false;
}
}
export async function deleteTrack(trackId: number): Promise<boolean> {
try {
const filePath = getTrackFilePath(trackId);
const info = await FileSystem.getInfoAsync(filePath);
if (info.exists) {
await FileSystem.deleteAsync(filePath);
await removeDownloadedTrack(trackId);
return true;
}
return false;
} catch (err) {
console.warn('Failed to delete track:', err);
return false;
}
}
export async function getStorageInfo(): Promise<{ used: number; total: number } | null> {
try {
const info = await FileSystem.getInfoAsync(DOCUMENTS_DIR);
if (!info.exists) return null;
// Get all files
const files = await FileSystem.readDirectoryAsync(DOCUMENTS_DIR);
let used = 0;
for (const file of files) {
const fileInfo = await FileSystem.getInfoAsync(`${DOCUMENTS_DIR}${file}`);
if (fileInfo.size) used += fileInfo.size;
}
return { used, total: 5 * 1024 * 1024 * 1024 }; // 5GB limit
} catch {
return null;
}
}

View file

@ -1,109 +1,170 @@
import { create } from 'zustand'; import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Track, User } from '../services/api'; import { Track, User } from '../services/api';
// ── Player ──────────────────────────────────────────────────────────────────── // ── Offline / Downloads ───────────────────────────────────────────────────────
interface PlayerState { interface OfflineState {
queue: Track[]; downloadedTracks: Set<number>;
index: number; downloading: Map<number, number>; // trackId -> progress 0-1
playing: boolean; isOnline: boolean;
progress: number;
duration: number; addDownloadedTrack: (id: number) => void;
repeat: 0 | 1 | 2; removeDownloadedTrack: (id: number) => void;
shuffle: boolean; setDownloading: (id: number, progress: number) => void;
expanded: boolean; clearDownloading: (id: number) => void;
loadDownloadedTracks: () => Promise<void>;
setQueue: (tracks: Track[], index?: number) => void; setOnline: (online: boolean) => void;
setIndex: (i: number) => void; }
setPlaying: (v: boolean) => void;
setProgress: (v: number) => void; export const useOffline = create<OfflineState>((set, get) => ({
setDuration: (v: number) => void; downloadedTracks: new Set(),
cycleRepeat: () => void; downloading: new Map(),
toggleShuffle: () => void; isOnline: true,
setExpanded: (v: boolean) => void;
nextTrack: () => number; addDownloadedTrack: (id) => {
prevTrack: () => number; const next = new Set(get().downloadedTracks);
} next.add(id);
set({ downloadedTracks: next });
export const usePlayer = create<PlayerState>((set, get) => ({ },
queue: [],
index: -1, removeDownloadedTrack: (id) => {
playing: false, const next = new Set(get().downloadedTracks);
progress: 0, next.delete(id);
duration: 0, set({ downloadedTracks: next });
repeat: 0, },
shuffle: false,
expanded: false, setDownloading: (id, progress) => {
const next = new Map(get().downloading);
setQueue: (tracks, index = 0) => set({ queue: tracks, index }), if (progress >= 1) {
setIndex: (index) => set({ index }), next.delete(id);
setPlaying: (playing) => set({ playing }), } else {
setProgress: (progress) => set({ progress }), next.set(id, progress);
setDuration: (duration) => set({ duration }), }
cycleRepeat: () => set((s) => ({ repeat: ((s.repeat + 1) % 3) as 0 | 1 | 2 })), set({ downloading: next });
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })), },
setExpanded: (expanded) => set({ expanded }),
clearDownloading: (id) => {
nextTrack: () => { const next = new Map(get().downloading);
const { queue, index, shuffle, repeat } = get(); next.delete(id);
if (repeat === 2) return index; set({ downloading: next });
if (shuffle) return Math.floor(Math.random() * queue.length); },
const next = index + 1;
if (next >= queue.length) return repeat === 1 ? 0 : index; loadDownloadedTracks: async () => {
return next; try {
}, const { getDownloadedTracks } = await import('../services/offline');
prevTrack: () => { const ids = await getDownloadedTracks();
const { queue, index, shuffle } = get(); set({ downloadedTracks: new Set(ids) });
if (shuffle) return Math.floor(Math.random() * queue.length); } catch (err) {
return index - 1 < 0 ? queue.length - 1 : index - 1; console.warn('Failed to load downloaded tracks:', err);
}, }
})); },
// ── Auth ────────────────────────────────────────────────────────────────────── setOnline: (online) => set({ isOnline: online }),
}));
interface AuthState {
user: User | null; // ── Player ────────────────────────────────────────────────────────────────────
ready: boolean;
setUser: (u: User | null) => void; interface PlayerState {
setReady: (v: boolean) => void; queue: Track[];
} index: number;
playing: boolean;
export const useAuth = create<AuthState>((set) => ({ progress: number;
user: null, duration: number;
ready: false, repeat: 0 | 1 | 2;
setUser: (user) => set({ user }), shuffle: boolean;
setReady: (ready) => set({ ready }), expanded: boolean;
}));
setQueue: (tracks: Track[], index?: number) => void;
// ── Likes ───────────────────────────────────────────────────────────────────── setIndex: (i: number) => void;
setPlaying: (v: boolean) => void;
const LIKES_KEY = 'ns-music-likes'; setProgress: (v: number) => void;
setDuration: (v: number) => void;
interface LikesState { cycleRepeat: () => void;
likes: Set<number>; toggleShuffle: () => void;
loadLikes: () => Promise<void>; setExpanded: (v: boolean) => void;
toggleLike: (id: number) => void; nextTrack: () => number;
isLiked: (id: number) => boolean; prevTrack: () => number;
} }
export const useLikes = create<LikesState>((set, get) => ({ export const usePlayer = create<PlayerState>((set, get) => ({
likes: new Set(), queue: [],
index: -1,
loadLikes: async () => { playing: false,
try { progress: 0,
const raw = await AsyncStorage.getItem(LIKES_KEY); duration: 0,
const arr: number[] = raw ? JSON.parse(raw) : []; repeat: 0,
set({ likes: new Set(arr) }); shuffle: false,
} catch {} expanded: false,
},
setQueue: (tracks, index = 0) => set({ queue: tracks, index }),
toggleLike: (id) => { setIndex: (index) => set({ index }),
const next = new Set(get().likes); setPlaying: (playing) => set({ playing }),
if (next.has(id)) next.delete(id); else next.add(id); setProgress: (progress) => set({ progress }),
set({ likes: next }); setDuration: (duration) => set({ duration }),
AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {}); cycleRepeat: () => set((s) => ({ repeat: ((s.repeat + 1) % 3) as 0 | 1 | 2 })),
}, toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
setExpanded: (expanded) => set({ expanded }),
isLiked: (id) => get().likes.has(id),
})); nextTrack: () => {
const { queue, index, shuffle, repeat } = get();
if (repeat === 2) return index;
if (shuffle) return Math.floor(Math.random() * queue.length);
const next = index + 1;
if (next >= queue.length) return repeat === 1 ? 0 : index;
return next;
},
prevTrack: () => {
const { queue, index, shuffle } = get();
if (shuffle) return Math.floor(Math.random() * queue.length);
return index - 1 < 0 ? queue.length - 1 : index - 1;
},
}));
// ── Auth ──────────────────────────────────────────────────────────────────────
interface AuthState {
user: User | null;
ready: boolean;
setUser: (u: User | null) => void;
setReady: (v: boolean) => void;
}
export const useAuth = create<AuthState>((set) => ({
user: null,
ready: false,
setUser: (user) => set({ user }),
setReady: (ready) => set({ ready }),
}));
// ── Likes ─────────────────────────────────────────────────────────────────────
const LIKES_KEY = 'ns-music-likes';
interface LikesState {
likes: Set<number>;
loadLikes: () => Promise<void>;
toggleLike: (id: number) => void;
isLiked: (id: number) => boolean;
}
export const useLikes = create<LikesState>((set, get) => ({
likes: new Set(),
loadLikes: async () => {
try {
const raw = await AsyncStorage.getItem(LIKES_KEY);
const arr: number[] = raw ? JSON.parse(raw) : [];
set({ likes: new Set(arr) });
} catch {}
},
toggleLike: (id) => {
const next = new Set(get().likes);
if (next.has(id)) next.delete(id); else next.add(id);
set({ likes: next });
AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {});
},
isLiked: (id) => get().likes.has(id),
}));

View file

@ -1,10 +1,10 @@
{ {
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]
} }
} }
} }

View file

@ -1,56 +1,56 @@
name: Build iOS IPA name: Build iOS IPA
on: on:
push: push:
branches: [main, master] branches: [main, master]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node 20 - name: Setup Node 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Expo prebuild - name: Expo prebuild
run: npx expo prebuild --platform ios --clean run: npx expo prebuild --platform ios --clean
env: env:
EXPO_NO_DOTENV: 1 EXPO_NO_DOTENV: 1
- name: Build unsigned archive - name: Build unsigned archive
run: | run: |
xcodebuild archive \ xcodebuild archive \
-workspace "ios/Burmalda Music.xcworkspace" \ -workspace "ios/Burmalda Music.xcworkspace" \
-scheme "Burmalda Music" \ -scheme "Burmalda Music" \
-configuration Release \ -configuration Release \
-sdk iphoneos \ -sdk iphoneos \
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \ -archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \ CODE_SIGN_IDENTITY="" \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
ENABLE_BITCODE=NO \ ENABLE_BITCODE=NO \
| xcpretty || true | xcpretty || true
- name: Package IPA - name: Package IPA
run: | run: |
mkdir -p Payload mkdir -p Payload
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/ cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/
zip -qr burmalda-unsigned.ipa Payload zip -qr burmalda-unsigned.ipa Payload
rm -rf Payload rm -rf Payload
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: burmalda-unsigned-ipa name: burmalda-unsigned-ipa
path: burmalda-unsigned.ipa path: burmalda-unsigned.ipa
retention-days: 30 retention-days: 30

View file

@ -1,56 +1,56 @@
name: Build iOS IPA name: Build iOS IPA
on: on:
push: push:
branches: [main, master] branches: [main, master]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node 20 - name: Setup Node 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Expo prebuild - name: Expo prebuild
run: npx expo prebuild --platform ios --clean run: npx expo prebuild --platform ios --clean
env: env:
EXPO_NO_DOTENV: 1 EXPO_NO_DOTENV: 1
- name: Build unsigned archive - name: Build unsigned archive
run: | run: |
xcodebuild archive \ xcodebuild archive \
-workspace "ios/Burmalda Music.xcworkspace" \ -workspace "ios/Burmalda Music.xcworkspace" \
-scheme "Burmalda Music" \ -scheme "Burmalda Music" \
-configuration Release \ -configuration Release \
-sdk iphoneos \ -sdk iphoneos \
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \ -archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \ CODE_SIGN_IDENTITY="" \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
ENABLE_BITCODE=NO \ ENABLE_BITCODE=NO \
| xcpretty || true | xcpretty || true
- name: Package IPA - name: Package IPA
run: | run: |
mkdir -p Payload mkdir -p Payload
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/ cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/
zip -qr burmalda-unsigned.ipa Payload zip -qr burmalda-unsigned.ipa Payload
rm -rf Payload rm -rf Payload
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: burmalda-unsigned-ipa name: burmalda-unsigned-ipa
path: burmalda-unsigned.ipa path: burmalda-unsigned.ipa
retention-days: 30 retention-days: 30