Add files via upload
This commit is contained in:
parent
d612a65f81
commit
5748d2d13c
31 changed files with 3197 additions and 2492 deletions
117
app.json
117
app.json
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
330
app/auth.tsx
330
app/auth.tsx
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
112
build-ios.yml
112
build-ios.yml
|
|
@ -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
85
build.yml
Normal 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"
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
components/DownloadButton.tsx
Normal file
101
components/DownloadButton.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
52
eas.json
52
eas.json
|
|
@ -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": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
116
hooks/useAuth.ts
116
hooks/useAuth.ts
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
index.js
10
index.js
|
|
@ -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
112
main.yml
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
86
package.json
86
package.json
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
373
services/api.ts
373
services/api.ts
|
|
@ -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
193
services/offline.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
279
store/index.ts
279
store/index.ts
|
|
@ -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),
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"extends": "expo/tsconfig.base",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue