Add files via upload
This commit is contained in:
commit
675493bafc
28 changed files with 2176 additions and 0 deletions
95
README.md
Normal file
95
README.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Burmalda Music — iOS App
|
||||||
|
|
||||||
|
React Native + Expo приложение для NodeShift Music.
|
||||||
|
|
||||||
|
## Требования (Windows)
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- npm или yarn
|
||||||
|
- Expo CLI: `npm install -g expo-cli`
|
||||||
|
- EAS CLI: `npm install -g eas-cli`
|
||||||
|
- Аккаунт на expo.dev (бесплатный)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/iosapp # или скопируй на Windows
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Локальный запуск (Expo Go / симулятор)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Сборка IPA (EAS Build — без Mac)
|
||||||
|
|
||||||
|
1. Войди в EAS:
|
||||||
|
```bash
|
||||||
|
eas login
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Создай проект (один раз):
|
||||||
|
```bash
|
||||||
|
eas build:configure
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Собери IPA:
|
||||||
|
```bash
|
||||||
|
# Preview (внутреннее распространение, без App Store)
|
||||||
|
eas build --platform ios --profile preview
|
||||||
|
|
||||||
|
# Production
|
||||||
|
eas build --platform ios --profile production
|
||||||
|
```
|
||||||
|
|
||||||
|
EAS собирает на своих серверах — Mac не нужен.
|
||||||
|
Готовый .ipa скачиваешь с expo.dev.
|
||||||
|
|
||||||
|
## Установка IPA без App Store
|
||||||
|
|
||||||
|
Используй **AltStore** или **Sideloadly**:
|
||||||
|
- Скачай AltStore на iPhone: altstore.io
|
||||||
|
- Установи IPA через AltStore на компьютере
|
||||||
|
|
||||||
|
## Автообновления (OTA)
|
||||||
|
|
||||||
|
После каждого изменения JS-кода (без нативных изменений):
|
||||||
|
```bash
|
||||||
|
eas update --branch production --message "Описание обновления"
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение автоматически скачает обновление при следующем запуске.
|
||||||
|
**Нет 7-дневного лимита** — EAS Update для production-сборок не имеет ограничений.
|
||||||
|
|
||||||
|
## Ассеты (нужно заменить)
|
||||||
|
|
||||||
|
- `assets/icon.png` — 1024×1024, иконка приложения (используй favicon NodeShift)
|
||||||
|
- `assets/splash.png` — 1284×2778, экран загрузки
|
||||||
|
|
||||||
|
Скачай favicon с nodeshift.space и сделай квадратный вариант 1024×1024.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
auth.tsx — Экран входа (OAuth NodeShift Cloud)
|
||||||
|
(tabs)/
|
||||||
|
index.tsx — Главная
|
||||||
|
library.tsx — Библиотека треков
|
||||||
|
search.tsx — Поиск
|
||||||
|
profile.tsx — Профиль
|
||||||
|
components/
|
||||||
|
Player.tsx — Полноэкранный плеер (стиль Apple Music)
|
||||||
|
MiniPlayer.tsx — Мини-плеер над табами
|
||||||
|
hooks/
|
||||||
|
useAudio.ts — Воспроизведение через expo-av
|
||||||
|
useAuth.ts — Авторизация
|
||||||
|
services/
|
||||||
|
api.ts — API клиент (music.nodeshift.space)
|
||||||
|
store/
|
||||||
|
index.ts — Zustand (плеер + авторизация)
|
||||||
|
constants/
|
||||||
|
theme.ts — Цвета, шрифты, отступы
|
||||||
|
```
|
||||||
57
app.json
Normal file
57
app.json
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Burmalda Music",
|
||||||
|
"slug": "burmalda-music",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#0a0a0a"
|
||||||
|
},
|
||||||
|
"assetBundlePatterns": ["**/*"],
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": false,
|
||||||
|
"bundleIdentifier": "space.nodeshift.burmalda",
|
||||||
|
"buildNumber": "1",
|
||||||
|
"infoPlist": {
|
||||||
|
"UIBackgroundModes": ["audio"],
|
||||||
|
"NSMicrophoneUsageDescription": "Не используется",
|
||||||
|
"CFBundleURLTypes": [
|
||||||
|
{
|
||||||
|
"CFBundleURLSchemes": ["burmalda"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scheme": "burmalda",
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-updates",
|
||||||
|
{
|
||||||
|
"username": "nodeshift"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"updates": {
|
||||||
|
"enabled": true,
|
||||||
|
"checkAutomatically": "ON_LOAD",
|
||||||
|
"url": "https://nodeshift.space/api/ios/updates"
|
||||||
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"apiBase": "https://music.nodeshift.space",
|
||||||
|
"cloudClientId": "nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U",
|
||||||
|
"eas": {}
|
||||||
|
|
||||||
|
},
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/(tabs)/_layout.tsx
Normal file
95
app/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { View, StyleSheet } from 'react-native';
|
||||||
|
import { Tabs, router } from 'expo-router';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
|
||||||
|
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
|
||||||
|
import { useAuth, usePlayer } from '../../store';
|
||||||
|
import MiniPlayer from '../../components/MiniPlayer';
|
||||||
|
import Player from '../../components/Player';
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
const { user, ready } = useAuth();
|
||||||
|
const { expanded } = usePlayer();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready && !user) {
|
||||||
|
router.replace('/auth');
|
||||||
|
}
|
||||||
|
}, [user, ready]);
|
||||||
|
|
||||||
|
if (!ready || !user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: Colors.bg }}>
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarStyle: styles.tabBar,
|
||||||
|
tabBarBackground: () => (
|
||||||
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
),
|
||||||
|
tabBarActiveTintColor: Colors.green,
|
||||||
|
tabBarInactiveTintColor: Colors.text3,
|
||||||
|
tabBarLabelStyle: { fontSize: 10, fontWeight: '600', marginBottom: 4 },
|
||||||
|
tabBarItemStyle: { paddingTop: 8 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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="search" options={{ title: 'Поиск', tabBarIcon: ({ color }) => <SearchIcon color={color} /> }} />
|
||||||
|
<Tabs.Screen name="profile" options={{ title: 'Профиль', tabBarIcon: ({ color }) => <ProfileIcon color={color} /> }} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<MiniPlayer />
|
||||||
|
{expanded && <Player />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<Svg width={22} height={22} 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" />
|
||||||
|
<Polyline points="9,22 9,12 15,12 15,22" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LibIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
|
||||||
|
<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={18} cy={16} r={3} stroke={color} strokeWidth={1.8} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
|
||||||
|
<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" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<Svg width={22} height={22} 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" />
|
||||||
|
<Circle cx={12} cy={7} r={4} stroke={color} strokeWidth={1.8} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
tabBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
borderTopWidth: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
elevation: 0,
|
||||||
|
height: TAB_BAR_HEIGHT,
|
||||||
|
},
|
||||||
|
});
|
||||||
172
app/(tabs)/index.tsx
Normal file
172
app/(tabs)/index.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, StyleSheet, Pressable,
|
||||||
|
Dimensions, RefreshControl,
|
||||||
|
} from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||||
|
import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
|
||||||
|
import { usePlayer } from '../../store';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const CARD_W = width * 0.42;
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const [tracks, setTracks] = useState<Track[]>([]);
|
||||||
|
const [albums, setAlbums] = useState<Album[]>([]);
|
||||||
|
const [refresh, setRefresh] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { setQueue, queue } = usePlayer();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [t, a] = await Promise.all([getTracks(), getAlbums()]);
|
||||||
|
setTracks(t);
|
||||||
|
setAlbums(a);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefresh(true);
|
||||||
|
await load();
|
||||||
|
setRefresh(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playTrack = (track: Track, index: number) => {
|
||||||
|
setQueue(tracks, index);
|
||||||
|
usePlayer.getState().setExpanded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.content,
|
||||||
|
{ paddingTop: insets.top + Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }
|
||||||
|
]}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.green} />
|
||||||
|
}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Animated.View entering={FadeIn.duration(500)}>
|
||||||
|
<Text style={styles.greeting}>Добро пожаловать</Text>
|
||||||
|
<Text style={styles.title}>NodeShift Music</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Albums row */}
|
||||||
|
{albums.length > 0 && (
|
||||||
|
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Альбомы</Text>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hScroll}>
|
||||||
|
{albums.map((a, i) => (
|
||||||
|
<AlbumCard key={a.id} album={a} index={i}
|
||||||
|
onPress={() => {/* open album */}} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tracks list */}
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Треки</Text>
|
||||||
|
{tracks.map((t, i) => (
|
||||||
|
<TrackRow key={t.id} track={t} index={i} onPress={() => playTrack(t, i)} />
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumCard({ album, index, onPress }: { album: Album; index: number; onPress: () => void }) {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
|
const cover = coverUrl(album.cover_url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(index * 60)} style={style}>
|
||||||
|
<Pressable
|
||||||
|
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
|
||||||
|
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||||
|
onPress={onPress}
|
||||||
|
style={styles.albumCard}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||||
|
style={styles.albumCover}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
<Text style={styles.albumTitle} numberOfLines={1}>{album.title}</Text>
|
||||||
|
<Text style={styles.albumArtist} numberOfLines={1}>{album.artist}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackRow({ track, index, onPress }: { track: Track; index: number; onPress: () => void }) {
|
||||||
|
const { queue, index: qi } = usePlayer();
|
||||||
|
const isPlaying = queue[qi]?.id === track.id;
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
|
const cover = coverUrl(track.cover_url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(index * 40).duration(300)} style={style}>
|
||||||
|
<Pressable
|
||||||
|
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
|
||||||
|
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.trackRow, isPlaying && styles.trackRowActive]}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||||
|
style={styles.trackCover}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
<View style={styles.trackInfo}>
|
||||||
|
<Text style={[styles.trackTitle, isPlaying && { color: Colors.green }]} numberOfLines={1}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(s: number) {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const 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 },
|
||||||
|
greeting: { color: Colors.text3, fontSize: Font.sm, fontWeight: '600', marginBottom: 4 },
|
||||||
|
title: { color: Colors.text, fontSize: Font.xxxl, fontWeight: '800', letterSpacing: -1, marginBottom: Spacing.xl },
|
||||||
|
section: { marginBottom: Spacing.xl },
|
||||||
|
sectionTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
|
||||||
|
hScroll: { paddingRight: Spacing.md, gap: Spacing.md },
|
||||||
|
albumCard: { width: CARD_W },
|
||||||
|
albumCover: { width: CARD_W, height: CARD_W, borderRadius: Radius.md, marginBottom: Spacing.sm, backgroundColor: Colors.elevated },
|
||||||
|
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
||||||
|
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
||||||
|
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: Spacing.sm, borderRadius: Radius.md, gap: Spacing.md },
|
||||||
|
trackRowActive: { backgroundColor: Colors.surface },
|
||||||
|
trackCover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||||
|
trackInfo: { flex: 1 },
|
||||||
|
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
|
trackArtist:{ color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||||
|
trackDur: { color: Colors.text3, fontSize: Font.xs },
|
||||||
|
});
|
||||||
167
app/(tabs)/library.tsx
Normal file
167
app/(tabs)/library.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, FlatList, StyleSheet, TextInput, Pressable,
|
||||||
|
} from 'react-native';
|
||||||
|
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import Svg, { Circle, Line } from 'react-native-svg';
|
||||||
|
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||||
|
import { getTracks, Track, coverUrl } from '../../services/api';
|
||||||
|
import { usePlayer } from '../../store';
|
||||||
|
|
||||||
|
export default function LibraryScreen() {
|
||||||
|
const [all, setAll] = useState<Track[]>([]);
|
||||||
|
const [filtered, setFiltered] = useState<Track[]>([]);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { setQueue } = usePlayer();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTracks().then(t => { setAll(t); setFiltered(t); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
setFiltered(q ? all.filter(t =>
|
||||||
|
t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q)
|
||||||
|
) : all);
|
||||||
|
}, [query, all]);
|
||||||
|
|
||||||
|
const playTrack = (track: Track, index: number) => {
|
||||||
|
setQueue(filtered, index);
|
||||||
|
usePlayer.getState().setExpanded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Библиотека</Text>
|
||||||
|
<Text style={styles.count}>{all.length} треков</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<View style={styles.searchWrap}>
|
||||||
|
<SearchSvg />
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Поиск треков..."
|
||||||
|
placeholderTextColor={Colors.text3}
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
returnKeyType="search"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{!!query && (
|
||||||
|
<Pressable onPress={() => setQuery('')} hitSlop={12}>
|
||||||
|
<Text style={styles.clearBtn}>✕</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={filtered}
|
||||||
|
keyExtractor={t => String(t.id)}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
|
paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackItem track={item} index={index} onPress={() => playTrack(item, index)} />
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackItem({ track, index, onPress }: { track: Track; index: number; onPress: () => void }) {
|
||||||
|
const { queue, index: qi } = usePlayer();
|
||||||
|
const active = queue[qi]?.id === track.id;
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
|
const cover = coverUrl(track.cover_url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(Math.min(index * 30, 300)).duration(300)} style={aStyle}>
|
||||||
|
<Pressable
|
||||||
|
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
|
||||||
|
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.row, active && styles.rowActive]}
|
||||||
|
>
|
||||||
|
<View style={styles.numWrap}>
|
||||||
|
{active
|
||||||
|
? <View style={styles.eqDots}>{[0,1,2].map(i => <EqDot key={i} delay={i * 150} />)}</View>
|
||||||
|
: <Text style={styles.num}>{index + 1}</Text>}
|
||||||
|
</View>
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||||
|
style={styles.cover}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.trackTitle, active && { color: Colors.green }]} numberOfLines={1}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.dur}>{fmt(track.duration)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EqDot({ delay }: { delay: number }) {
|
||||||
|
const h = useSharedValue(4);
|
||||||
|
const style = useAnimatedStyle(() => ({ height: h.value }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loop = () => {
|
||||||
|
h.value = withSpring(12 + Math.random() * 8, { damping: 6 }, () => {
|
||||||
|
h.value = withSpring(4, { damping: 6 }, loop);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const t = setTimeout(loop, delay);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <Animated.View style={[styles.dot, style]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchSvg() {
|
||||||
|
return (
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" style={{ marginLeft: 12 }}>
|
||||||
|
<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" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.bg },
|
||||||
|
header: { paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm, flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm },
|
||||||
|
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
|
||||||
|
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: Spacing.sm },
|
||||||
|
searchInput: { flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 12, paddingRight: Spacing.md },
|
||||||
|
clearBtn: { color: Colors.text3, fontSize: Font.sm, paddingRight: 12 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderRadius: Radius.sm, gap: Spacing.md },
|
||||||
|
rowActive: { backgroundColor: Colors.surface + '80' },
|
||||||
|
numWrap: { width: 28, alignItems: 'center' },
|
||||||
|
num: { color: Colors.text3, fontSize: Font.sm },
|
||||||
|
eqDots: { flexDirection: 'row', alignItems: 'flex-end', gap: 2, height: 16 },
|
||||||
|
dot: { width: 3, backgroundColor: Colors.green, borderRadius: 2 },
|
||||||
|
cover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||||
|
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
|
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||||
|
dur: { color: Colors.text3, fontSize: Font.xs, paddingRight: 4 },
|
||||||
|
sep: { height: 1 },
|
||||||
|
});
|
||||||
125
app/(tabs)/profile.tsx
Normal file
125
app/(tabs)/profile.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, Pressable, ScrollView, Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import Svg, { Path, Polyline } from 'react-native-svg';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||||
|
import { useAuth } from '../../store';
|
||||||
|
import { logout } from '../../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
Alert.alert('Выход', 'Выйти из аккаунта?', [
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Выйти', style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
await logout();
|
||||||
|
router.replace('/auth');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const initials = (user.username || user.email).slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.content,
|
||||||
|
{ paddingTop: insets.top + Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 },
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<Text style={styles.pageTitle}>Профиль</Text>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeIn.duration(500)} style={styles.avatarCard}>
|
||||||
|
<LinearGradient colors={['#1e1e1e', '#141414']} style={styles.avatarCardGrad}>
|
||||||
|
{user.avatar_url ? (
|
||||||
|
<Image source={{ uri: user.avatar_url }} style={styles.avatar} contentFit="cover" />
|
||||||
|
) : (
|
||||||
|
<View style={styles.avatarPlaceholder}>
|
||||||
|
<Text style={styles.avatarInitials}>{initials}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.displayName}>{user.username || 'Пользователь'}</Text>
|
||||||
|
<Text style={styles.email}>{user.email}</Text>
|
||||||
|
<View style={[styles.planBadge, user.plan !== 'free' && styles.planBadgePro]}>
|
||||||
|
<Text style={[styles.planText, user.plan !== 'free' && styles.planTextPro]}>
|
||||||
|
{user.plan === 'free' ? 'Free' : 'Pro'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
||||||
|
<Text style={styles.sectionLabel}>Аккаунт</Text>
|
||||||
|
<InfoRow label="Email" value={user.email} />
|
||||||
|
<InfoRow label="Никнейм" value={user.username || '—'} />
|
||||||
|
<InfoRow label="Тариф" value={user.plan === 'free' ? 'Бесплатный' : 'Pro'} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.delay(200)}>
|
||||||
|
<Pressable style={styles.logoutBtn} onPress={handleLogout}>
|
||||||
|
<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" />
|
||||||
|
<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" />
|
||||||
|
</Svg>
|
||||||
|
<Text style={styles.logoutText}>Выйти из аккаунта</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.Text entering={FadeInDown.delay(300)} style={styles.version}>
|
||||||
|
Burmalda Music · v1.0.0 · by NodeShift
|
||||||
|
</Animated.Text>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>{label}</Text>
|
||||||
|
<Text style={styles.infoValue} numberOfLines={1}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scroll: { flex: 1, backgroundColor: Colors.bg },
|
||||||
|
content: { paddingHorizontal: Spacing.md },
|
||||||
|
pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: Spacing.lg },
|
||||||
|
avatarCard: { borderRadius: Radius.xl, overflow: 'hidden', marginBottom: Spacing.xl },
|
||||||
|
avatarCardGrad: { padding: Spacing.xl, alignItems: 'center', gap: 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 },
|
||||||
|
avatarInitials: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800' },
|
||||||
|
displayName: { color: Colors.text, fontSize: Font.xl, fontWeight: '700' },
|
||||||
|
email: { color: Colors.text2, fontSize: Font.sm },
|
||||||
|
planBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: Radius.full, backgroundColor: Colors.elevated, marginTop: 4 },
|
||||||
|
planBadgePro: { backgroundColor: Colors.green + '22', borderWidth: 1, borderColor: Colors.green + '44' },
|
||||||
|
planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' },
|
||||||
|
planTextPro: { color: Colors.green },
|
||||||
|
section: { marginBottom: Spacing.lg },
|
||||||
|
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 },
|
||||||
|
infoLabel: { color: Colors.text2, fontSize: Font.md },
|
||||||
|
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 },
|
||||||
|
logoutText: { color: Colors.red, fontSize: Font.md, fontWeight: '600' },
|
||||||
|
version: { color: Colors.text3, fontSize: Font.xs, textAlign: 'center' },
|
||||||
|
});
|
||||||
136
app/(tabs)/search.tsx
Normal file
136
app/(tabs)/search.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard,
|
||||||
|
} from 'react-native';
|
||||||
|
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||||
|
import { searchTracks, Track, coverUrl } from '../../services/api';
|
||||||
|
import { usePlayer } from '../../store';
|
||||||
|
|
||||||
|
export default function SearchScreen() {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<Track[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { setQueue } = usePlayer();
|
||||||
|
|
||||||
|
const doSearch = useCallback(async (q: string) => {
|
||||||
|
if (!q.trim()) { setResults([]); setSearched(false); return; }
|
||||||
|
setLoading(true);
|
||||||
|
setSearched(true);
|
||||||
|
const res = await searchTracks(q.trim());
|
||||||
|
setResults(res);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playTrack = (index: number) => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
setQueue(results, index);
|
||||||
|
usePlayer.getState().setExpanded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||||
|
<Text style={styles.title}>Поиск</Text>
|
||||||
|
|
||||||
|
<View style={styles.inputWrap}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Треки, исполнители..."
|
||||||
|
placeholderTextColor={Colors.text3}
|
||||||
|
value={query}
|
||||||
|
onChangeText={q => { setQuery(q); doSearch(q); }}
|
||||||
|
returnKeyType="search"
|
||||||
|
onSubmitEditing={() => doSearch(query)}
|
||||||
|
autoCorrect={false}
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!searched && (
|
||||||
|
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
||||||
|
<Text style={styles.emptyIcon}>🎵</Text>
|
||||||
|
<Text style={styles.emptyText}>Введите название трека{'\n'}или имя исполнителя</Text>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searched && !loading && results.length === 0 && (
|
||||||
|
<Animated.View entering={FadeIn} style={styles.emptyWrap}>
|
||||||
|
<Text style={styles.emptyIcon}>🔍</Text>
|
||||||
|
<Text style={styles.emptyText}>Ничего не найдено</Text>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={results}
|
||||||
|
keyExtractor={t => String(t.id)}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
|
paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<SearchResult track={item} index={index} onPress={() => playTrack(index)} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResult({ track, index, onPress }: { track: Track; index: number; onPress: () => void }) {
|
||||||
|
const { queue, index: qi } = usePlayer();
|
||||||
|
const active = queue[qi]?.id === track.id;
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
|
const cover = coverUrl(track.cover_url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(index * 40).duration(250)} style={aStyle}>
|
||||||
|
<Pressable
|
||||||
|
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
|
||||||
|
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.row, active && styles.rowActive]}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||||
|
style={styles.cover}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.trackTitle, active && { color: Colors.green }]} numberOfLines={1}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.dur}>{fmt(track.duration)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
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 },
|
||||||
|
inputWrap: { marginHorizontal: Spacing.md, marginBottom: Spacing.md, backgroundColor: Colors.surface, borderRadius: Radius.md, paddingHorizontal: Spacing.md },
|
||||||
|
input: { color: Colors.text, fontSize: Font.md, paddingVertical: 13 },
|
||||||
|
emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 },
|
||||||
|
emptyIcon: { fontSize: 48, marginBottom: Spacing.md },
|
||||||
|
emptyText: { color: Colors.text2, fontSize: Font.md, textAlign: 'center', lineHeight: 24 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm },
|
||||||
|
rowActive: { backgroundColor: Colors.surface + '80' },
|
||||||
|
cover: { width: 48, height: 48, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||||
|
trackTitle:{ color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
|
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||||
|
dur: { color: Colors.text3, fontSize: Font.xs },
|
||||||
|
});
|
||||||
39
app/_layout.tsx
Normal file
39
app/_layout.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import * as Updates from 'expo-updates';
|
||||||
|
import { useBootAuth } from '../hooks/useAuth';
|
||||||
|
import { Colors } from '../constants/theme';
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
useBootAuth();
|
||||||
|
useOTAUpdates();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.bg } }}>
|
||||||
|
<Stack.Screen name="(tabs)" />
|
||||||
|
<Stack.Screen name="auth" options={{ presentation: 'fullScreenModal' }} />
|
||||||
|
</Stack>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useOTAUpdates() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (__DEV__) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const check = await Updates.checkForUpdateAsync();
|
||||||
|
if (check.isAvailable) {
|
||||||
|
await Updates.fetchUpdateAsync();
|
||||||
|
await Updates.reloadAsync();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent fail
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
163
app/auth.tsx
Normal file
163
app/auth.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, Pressable, ActivityIndicator, Dimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue, useAnimatedStyle, withSpring, withTiming,
|
||||||
|
FadeIn, SlideInDown,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import Svg, { Path, Circle, Polyline, Line } from 'react-native-svg';
|
||||||
|
import { Colors, Font, Radius, Spacing } from '../constants/theme';
|
||||||
|
import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api';
|
||||||
|
import { useAuth } from '../store';
|
||||||
|
|
||||||
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export default function AuthScreen() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const { setUser } = useAuth();
|
||||||
|
const btnScale = useSharedValue(1);
|
||||||
|
|
||||||
|
const btnStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: btnScale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleLogin = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
||||||
|
const url = buildOAuthUrl(state);
|
||||||
|
const result = await WebBrowser.openAuthSessionAsync(url, 'burmalda://auth/callback');
|
||||||
|
|
||||||
|
if (result.type !== 'success') {
|
||||||
|
setError('Авторизация отменена');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = Linking.parse(result.url);
|
||||||
|
const code = parsed.queryParams?.code as string | undefined;
|
||||||
|
if (!code) {
|
||||||
|
setError((parsed.queryParams?.error as string) || 'Ошибка авторизации');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await exchangeCode(code);
|
||||||
|
await saveTokens(data.access_token, data.refresh_token);
|
||||||
|
setUser(data.user);
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Что-то пошло не так');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [setUser]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Blob color="#1db954" size={420} left={-80} top={-100} delay={0} />
|
||||||
|
<Blob color="#6c63ff" size={320} left={width - 200} top={height * 0.55} delay={2000} />
|
||||||
|
<Blob color="#1db954" size={240} left={width * 0.3} top={height - 100} delay={4000} />
|
||||||
|
|
||||||
|
<Animated.View style={styles.card} entering={FadeIn.duration(600)}>
|
||||||
|
<Animated.View entering={SlideInDown.delay(100).springify()} style={styles.logoWrap}>
|
||||||
|
<LinearGradient colors={['#1db954', '#168f3f']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.logoIcon}>
|
||||||
|
<Svg width={38} height={38} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M9 18V5l12-2v13" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||||
|
<Circle cx={6} cy={18} r={3} fill="#fff" />
|
||||||
|
<Circle cx={18} cy={16} r={3} fill="#fff" />
|
||||||
|
</Svg>
|
||||||
|
</LinearGradient>
|
||||||
|
<Text style={styles.logoTitle}>Burmalda Music</Text>
|
||||||
|
<Text style={styles.logoSub}>by NodeShift</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={SlideInDown.delay(200).springify()} style={{ width: '100%' }}>
|
||||||
|
<Animated.View style={btnStyle}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.btnWrap}
|
||||||
|
disabled={loading}
|
||||||
|
onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 14 }); }}
|
||||||
|
onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }}
|
||||||
|
onPress={handleLogin}
|
||||||
|
>
|
||||||
|
<LinearGradient colors={['#1db954', '#17a349']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.btn}>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator color="#000" size="small" />
|
||||||
|
: (
|
||||||
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" stroke="#000" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Polyline points="8 17 12 13 16 17" stroke="#000" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Line x1="12" y1="13" x2="12" y2="21" stroke="#000" strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
)}
|
||||||
|
<Text style={styles.btnText}>
|
||||||
|
{loading ? 'Ожидаем авторизацию…' : 'Войти через NodeShift'}
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{!!error && (
|
||||||
|
<Animated.Text entering={FadeIn} style={styles.error}>{error}</Animated.Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.Text entering={FadeIn.delay(300)} style={styles.footer}>
|
||||||
|
Нажимая «Войти», вы соглашаетесь с{'\n'}условиями использования NodeShift
|
||||||
|
</Animated.Text>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Blob({ color, size, left, top, delay }: {
|
||||||
|
color: string; size: number; left: number; top: number; delay: number;
|
||||||
|
}) {
|
||||||
|
const tx = useSharedValue(0);
|
||||||
|
const ty = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animate = () => {
|
||||||
|
tx.value = withTiming(25, { duration: 8000 }, () =>
|
||||||
|
tx.value = withTiming(-20, { duration: 8000 }, () =>
|
||||||
|
tx.value = withTiming(0, { duration: 8000 }, animate)));
|
||||||
|
ty.value = withTiming(-35, { duration: 8000 }, () =>
|
||||||
|
ty.value = withTiming(18, { duration: 8000 }, () =>
|
||||||
|
ty.value = withTiming(0, { duration: 8000 })));
|
||||||
|
};
|
||||||
|
const t = setTimeout(animate, delay);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const style = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: tx.value }, { translateY: ty.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.blob, style, {
|
||||||
|
width: size, height: size, borderRadius: size / 2,
|
||||||
|
backgroundColor: color, left, top,
|
||||||
|
}]} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
||||||
|
blob: { position: 'absolute', opacity: 0.15 },
|
||||||
|
card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 },
|
||||||
|
logoWrap: { alignItems: 'center', marginBottom: 40 },
|
||||||
|
logoIcon: { width: 84, height: 84, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.md, shadowColor: '#1db954', shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 },
|
||||||
|
logoTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: 4 },
|
||||||
|
logoSub: { color: Colors.text3, fontSize: Font.sm, fontWeight: '500' },
|
||||||
|
btnWrap: { width: '100%', borderRadius: Radius.md, overflow: 'hidden', shadowColor: '#1db954', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 },
|
||||||
|
btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 },
|
||||||
|
btnText: { color: '#000', fontSize: Font.md, fontWeight: '700' },
|
||||||
|
error: { color: Colors.red, fontSize: Font.sm, textAlign: 'center', marginTop: Spacing.sm },
|
||||||
|
footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 },
|
||||||
|
});
|
||||||
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/placeholder.png
Normal file
BIN
assets/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 B |
BIN
assets/splash.png
Normal file
BIN
assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
7
babel.config.js
Normal file
7
babel.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['react-native-reanimated/plugin'],
|
||||||
|
};
|
||||||
|
};
|
||||||
56
build-ios.yml
Normal file
56
build-ios.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
name: Build iOS IPA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Expo prebuild
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
env:
|
||||||
|
EXPO_NO_DOTENV: 1
|
||||||
|
|
||||||
|
- name: Build unsigned archive
|
||||||
|
run: |
|
||||||
|
xcodebuild archive \
|
||||||
|
-workspace "ios/Burmalda Music.xcworkspace" \
|
||||||
|
-scheme "Burmalda Music" \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
ENABLE_BITCODE=NO \
|
||||||
|
| xcpretty || true
|
||||||
|
|
||||||
|
- name: Package IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p Payload
|
||||||
|
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/
|
||||||
|
zip -qr burmalda-unsigned.ipa Payload
|
||||||
|
rm -rf Payload
|
||||||
|
|
||||||
|
- name: Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: burmalda-unsigned-ipa
|
||||||
|
path: burmalda-unsigned.ipa
|
||||||
|
retention-days: 30
|
||||||
141
components/MiniPlayer.tsx
Normal file
141
components/MiniPlayer.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue, useAnimatedStyle, withSpring, FadeInDown, FadeOutDown,
|
||||||
|
useAnimatedGestureHandler, runOnJS,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { PanGestureHandler } from 'react-native-gesture-handler';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { Colors, Font, Radius, Spacing, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../constants/theme';
|
||||||
|
import { usePlayer } from '../store';
|
||||||
|
import { useAudio } from '../hooks/useAudio';
|
||||||
|
import { coverUrl } from '../services/api';
|
||||||
|
|
||||||
|
export default function MiniPlayer() {
|
||||||
|
const { queue, index, playing, expanded, setExpanded } = usePlayer();
|
||||||
|
const { togglePlay } = useAudio();
|
||||||
|
const track = queue[index];
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
const gestureHandler = useAnimatedGestureHandler({
|
||||||
|
onActive: (e) => {
|
||||||
|
if (e.translationY < 0) {
|
||||||
|
translateY.value = Math.max(e.translationY, -80);
|
||||||
|
opacity.value = 1 + e.translationY / 80;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEnd: (e) => {
|
||||||
|
if (e.translationY < -40 || e.velocityY < -400) {
|
||||||
|
runOnJS(openPlayer)();
|
||||||
|
}
|
||||||
|
translateY.value = withSpring(0);
|
||||||
|
opacity.value = withSpring(1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const animStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const openPlayer = useCallback(() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
setExpanded(true);
|
||||||
|
}, [setExpanded]);
|
||||||
|
|
||||||
|
const onPlayPress = useCallback(() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
togglePlay();
|
||||||
|
}, [togglePlay]);
|
||||||
|
|
||||||
|
if (!track || expanded) return null;
|
||||||
|
|
||||||
|
const cover = coverUrl(track.cover_url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanGestureHandler onGestureEvent={gestureHandler}>
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
|
||||||
|
entering={FadeInDown.springify()}
|
||||||
|
exiting={FadeOutDown.springify()}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
<View style={styles.border} />
|
||||||
|
|
||||||
|
<Pressable style={styles.row} onPress={openPlayer} android_ripple={null}>
|
||||||
|
{/* Cover */}
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
|
style={styles.cover}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.title} numberOfLines={1}>{track.title}</Text>
|
||||||
|
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Play/Pause */}
|
||||||
|
<Pressable
|
||||||
|
style={styles.btn}
|
||||||
|
onPress={onPlayPress}
|
||||||
|
hitSlop={12}
|
||||||
|
>
|
||||||
|
{playing ? <PauseIcon /> : <PlayIcon />}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<Pressable
|
||||||
|
style={styles.btn}
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
const next = usePlayer.getState().nextTrack();
|
||||||
|
usePlayer.getState().setIndex(next);
|
||||||
|
}}
|
||||||
|
hitSlop={12}
|
||||||
|
>
|
||||||
|
<NextIcon />
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Progress line */}
|
||||||
|
<ProgressLine />
|
||||||
|
</Animated.View>
|
||||||
|
</PanGestureHandler>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressLine() {
|
||||||
|
const { progress, duration } = usePlayer();
|
||||||
|
const pct = duration > 0 ? (progress / duration) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<View style={styles.progressBg}>
|
||||||
|
<View style={[styles.progressFill, { width: `${pct}%` as any }]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayIcon() { return <Svg width={20} height={20} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>; }
|
||||||
|
function PauseIcon() { return <Svg width={20} height={20} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg>; }
|
||||||
|
function NextIcon() { return <Svg width={20} height={20} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg>; }
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden' },
|
||||||
|
border: { position: 'absolute', top: 0, left: 0, right: 0, height: StyleSheet.hairlineWidth, backgroundColor: Colors.border },
|
||||||
|
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.md, gap: Spacing.md },
|
||||||
|
cover: { width: 42, height: 42, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
||||||
|
info: { flex: 1 },
|
||||||
|
title: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
||||||
|
artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
||||||
|
btn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
progressBg: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, backgroundColor: Colors.border },
|
||||||
|
progressFill:{ height: 2, backgroundColor: Colors.green },
|
||||||
|
});
|
||||||
309
components/Player.tsx
Normal file
309
components/Player.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import { useCallback, useEffect, type ReactNode } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Pressable, Dimensions } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue, useAnimatedStyle, withSpring,
|
||||||
|
interpolate, useAnimatedGestureHandler, runOnJS, FadeIn,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { PanGestureHandler } from 'react-native-gesture-handler';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import Slider from '@react-native-community/slider';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import Svg, { Path, Polyline } from 'react-native-svg';
|
||||||
|
import { Colors, Font, Radius, Spacing } from '../constants/theme';
|
||||||
|
import { usePlayer } from '../store';
|
||||||
|
import { useAudio } from '../hooks/useAudio';
|
||||||
|
import { coverUrl } from '../services/api';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const COVER_SIZE = width - 64;
|
||||||
|
|
||||||
|
export default function Player() {
|
||||||
|
const {
|
||||||
|
queue, index, playing, progress, duration,
|
||||||
|
repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle,
|
||||||
|
} = usePlayer();
|
||||||
|
const { togglePlay, seek } = useAudio();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const track = queue[index];
|
||||||
|
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
const coverScale = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 });
|
||||||
|
}, [playing]);
|
||||||
|
|
||||||
|
const gestureHandler = useAnimatedGestureHandler<any, { startY: number }>({
|
||||||
|
onStart: (_, ctx) => { ctx.startY = translateY.value; },
|
||||||
|
onActive: (e, ctx) => {
|
||||||
|
if (e.translationY > 0) translateY.value = ctx.startY + e.translationY;
|
||||||
|
},
|
||||||
|
onEnd: (e) => {
|
||||||
|
if (e.translationY > 120 || e.velocityY > 800) {
|
||||||
|
runOnJS(close)();
|
||||||
|
} else {
|
||||||
|
translateY.value = withSpring(0, { damping: 20 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const coverStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: coverScale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const overlayStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: interpolate(translateY.value, [0, 300], [1, 0]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
setExpanded(false);
|
||||||
|
}, [setExpanded]);
|
||||||
|
|
||||||
|
const onNext = useCallback(() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
const ni = usePlayer.getState().nextTrack();
|
||||||
|
usePlayer.getState().setIndex(ni);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPrev = useCallback(() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
if (progress > 3) { seek(0); return; }
|
||||||
|
const pi = usePlayer.getState().prevTrack();
|
||||||
|
usePlayer.getState().setIndex(pi);
|
||||||
|
}, [progress, seek]);
|
||||||
|
|
||||||
|
const onPlay = useCallback(() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
togglePlay();
|
||||||
|
}, [togglePlay]);
|
||||||
|
|
||||||
|
if (!track) return null;
|
||||||
|
|
||||||
|
const cover = coverUrl(track.cover_url);
|
||||||
|
const pct = duration > 0 ? progress / duration : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[StyleSheet.absoluteFill, styles.root, overlayStyle]} entering={FadeIn.duration(200)}>
|
||||||
|
{/* Blurred background art */}
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
contentFit="cover"
|
||||||
|
blurRadius={60}
|
||||||
|
/>
|
||||||
|
<View style={[StyleSheet.absoluteFill, styles.dim]} />
|
||||||
|
|
||||||
|
<PanGestureHandler onGestureEvent={gestureHandler}>
|
||||||
|
<Animated.View style={[
|
||||||
|
styles.sheet, containerStyle,
|
||||||
|
{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 24 },
|
||||||
|
]}>
|
||||||
|
{/* Handle */}
|
||||||
|
<Pressable onPress={close} hitSlop={20} style={styles.handleWrap}>
|
||||||
|
<View style={styles.handle} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Сейчас играет</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Cover */}
|
||||||
|
<View style={styles.coverWrap}>
|
||||||
|
<Animated.View style={[styles.coverShadow, coverStyle]}>
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
|
style={styles.cover}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info + like */}
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.trackTitle} numberOfLines={1}>{track.title}</Text>
|
||||||
|
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
</View>
|
||||||
|
<LikeButton />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<View style={styles.progressWrap}>
|
||||||
|
<Slider
|
||||||
|
style={{ width: '100%', height: 36 }}
|
||||||
|
minimumValue={0}
|
||||||
|
maximumValue={1}
|
||||||
|
value={pct}
|
||||||
|
minimumTrackTintColor={Colors.text}
|
||||||
|
maximumTrackTintColor={Colors.text3}
|
||||||
|
thumbTintColor={Colors.text}
|
||||||
|
onSlidingComplete={(v) => seek(v * duration)}
|
||||||
|
/>
|
||||||
|
<View style={styles.timeRow}>
|
||||||
|
<Text style={styles.timeText}>{fmt(progress)}</Text>
|
||||||
|
<Text style={styles.timeText}>{fmt(duration)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<View style={styles.controls}>
|
||||||
|
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
|
||||||
|
<ShuffleIcon active={shuffle} />
|
||||||
|
</CtlBtn>
|
||||||
|
|
||||||
|
<CtlBtn onPress={onPrev}>
|
||||||
|
<PrevIcon />
|
||||||
|
</CtlBtn>
|
||||||
|
|
||||||
|
<Pressable style={styles.playBtn} onPress={onPlay}>
|
||||||
|
{playing ? <PauseIcon /> : <PlayIcon />}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<CtlBtn onPress={onNext}>
|
||||||
|
<NextIcon />
|
||||||
|
</CtlBtn>
|
||||||
|
|
||||||
|
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
|
||||||
|
<RepeatIcon repeat={repeat} />
|
||||||
|
</CtlBtn>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</PanGestureHandler>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CtlBtn({ onPress, active = false, children }: {
|
||||||
|
onPress: () => void; active?: boolean; children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
|
return (
|
||||||
|
<Animated.View style={style}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.ctlBtn, active && styles.ctlBtnActive]}
|
||||||
|
onPress={onPress}
|
||||||
|
onPressIn={() => { scale.value = withSpring(0.82, { damping: 12 }); }}
|
||||||
|
onPressOut={() => { scale.value = withSpring(1, { damping: 12 }); }}
|
||||||
|
hitSlop={12}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LikeButton() {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
|
return (
|
||||||
|
<Animated.View style={style}>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={12}
|
||||||
|
onPress={() => {
|
||||||
|
scale.value = withSpring(1.35, { damping: 7 }, () => {
|
||||||
|
scale.value = withSpring(1, { damping: 12 });
|
||||||
|
});
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Svg width={24} height={24} 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={Colors.text2} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayIcon() {
|
||||||
|
return <Svg width={28} height={28} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M8 5v14l11-7z" /></Svg>;
|
||||||
|
}
|
||||||
|
function PauseIcon() {
|
||||||
|
return <Svg width={28} height={28} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg>;
|
||||||
|
}
|
||||||
|
function PrevIcon() {
|
||||||
|
return (
|
||||||
|
<Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.text}>
|
||||||
|
<Path d="M19 20L9 12l10-8v16z" />
|
||||||
|
<Path d="M5 19V5h2v14H5z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function NextIcon() {
|
||||||
|
return (
|
||||||
|
<Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.text}>
|
||||||
|
<Path d="M5 4l10 8-10 8V4z" />
|
||||||
|
<Path d="M19 5v14h-2V5h2z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function ShuffleIcon({ active }: { active: boolean }) {
|
||||||
|
const c = active ? Colors.green : Colors.text2;
|
||||||
|
return (
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Path d="M4 20L21 3" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Polyline points="21,16 21,21 16,21" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
|
||||||
|
const c = repeat > 0 ? Colors.green : Colors.text2;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Path d="M3 11V9a4 4 0 0 1 4-4h14" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
{repeat === 2 && (
|
||||||
|
<View style={styles.repeatBadge}>
|
||||||
|
<Text style={styles.repeatBadgeText}>1</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
root: { zIndex: 999 },
|
||||||
|
dim: { backgroundColor: 'rgba(0,0,0,0.6)' },
|
||||||
|
sheet: { flex: 1, paddingHorizontal: Spacing.xl },
|
||||||
|
handleWrap: { alignItems: 'center', paddingBottom: 4 },
|
||||||
|
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)' },
|
||||||
|
header: { alignItems: 'center', marginBottom: Spacing.lg },
|
||||||
|
headerTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600', letterSpacing: 0.5 },
|
||||||
|
coverWrap: { alignItems: 'center', marginBottom: Spacing.xl },
|
||||||
|
coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.6, shadowRadius: 32 },
|
||||||
|
cover: { width: COVER_SIZE, height: COVER_SIZE, borderRadius: Radius.xl, backgroundColor: Colors.elevated },
|
||||||
|
infoRow: { flexDirection: 'row', alignItems: 'center', marginBottom: Spacing.md, gap: Spacing.md },
|
||||||
|
trackTitle: { color: Colors.text, fontSize: Font.xl, fontWeight: '700', letterSpacing: -0.3 },
|
||||||
|
artist: { color: 'rgba(255,255,255,0.5)', fontSize: Font.md, marginTop: 4 },
|
||||||
|
progressWrap: { marginBottom: Spacing.lg },
|
||||||
|
timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -6 },
|
||||||
|
timeText: { color: 'rgba(255,255,255,0.35)', fontSize: Font.xs },
|
||||||
|
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4 },
|
||||||
|
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full },
|
||||||
|
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
|
||||||
|
playBtn: { width: 72, height: 72, borderRadius: 36, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center', shadowColor: '#fff', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 12 },
|
||||||
|
repeatBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.green, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
repeatBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' },
|
||||||
|
});
|
||||||
43
constants/theme.ts
Normal file
43
constants/theme.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
export const Colors = {
|
||||||
|
bg: '#0a0a0a',
|
||||||
|
surface: '#141414',
|
||||||
|
elevated: '#1e1e1e',
|
||||||
|
border: '#2a2a2a',
|
||||||
|
green: '#1db954',
|
||||||
|
greenDim: '#168f3f',
|
||||||
|
text: '#f0f0f0',
|
||||||
|
text2: '#888888',
|
||||||
|
text3: '#444444',
|
||||||
|
red: '#e84040',
|
||||||
|
white: '#ffffff',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Spacing = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
xxl: 48,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Radius = {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 20,
|
||||||
|
full: 9999,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Font = {
|
||||||
|
xs: 11,
|
||||||
|
sm: 13,
|
||||||
|
md: 15,
|
||||||
|
lg: 17,
|
||||||
|
xl: 20,
|
||||||
|
xxl: 26,
|
||||||
|
xxxl: 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TAB_BAR_HEIGHT = 88;
|
||||||
|
export const MINI_PLAYER_HEIGHT = 64;
|
||||||
26
eas.json
Normal file
26
eas.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 10.0.0",
|
||||||
|
"appVersionSource": "local"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"buildConfiguration": "Release"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
hooks/useAudio.ts
Normal file
96
hooks/useAudio.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Audio, AVPlaybackStatus } from 'expo-av';
|
||||||
|
import { usePlayer } from '../store';
|
||||||
|
|
||||||
|
let _sound: Audio.Sound | null = null;
|
||||||
|
|
||||||
|
// Module-level singleton so the sound survives re-renders
|
||||||
|
export async function getSound() { return _sound; }
|
||||||
|
|
||||||
|
export function useAudio() {
|
||||||
|
const initialized = useRef(false);
|
||||||
|
const {
|
||||||
|
queue, index, playing,
|
||||||
|
setPlaying, setProgress, setDuration,
|
||||||
|
nextTrack, setIndex, repeat,
|
||||||
|
} = usePlayer();
|
||||||
|
|
||||||
|
const currentTrack = queue[index] ?? null;
|
||||||
|
|
||||||
|
// Init audio session once
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialized.current) return;
|
||||||
|
initialized.current = true;
|
||||||
|
Audio.setAudioModeAsync({
|
||||||
|
staysActiveInBackground: true,
|
||||||
|
playsInSilentModeIOS: true,
|
||||||
|
interruptionModeIOS: 1,
|
||||||
|
shouldDuckAndroid: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load new track whenever index/id changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTrack) return;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
// Unload previous
|
||||||
|
if (_sound) {
|
||||||
|
await _sound.stopAsync().catch(() => {});
|
||||||
|
await _sound.unloadAsync().catch(() => {});
|
||||||
|
_sound = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sound } = await Audio.Sound.createAsync(
|
||||||
|
{ uri: currentTrack.stream_url },
|
||||||
|
{ shouldPlay: true, progressUpdateIntervalMillis: 500 },
|
||||||
|
(status: AVPlaybackStatus) => {
|
||||||
|
if (!status.isLoaded) return;
|
||||||
|
setProgress(status.positionMillis / 1000);
|
||||||
|
setDuration((status.durationMillis ?? 0) / 1000);
|
||||||
|
if (status.isPlaying !== usePlayer.getState().playing) {
|
||||||
|
setPlaying(status.isPlaying);
|
||||||
|
}
|
||||||
|
if (status.didJustFinish) {
|
||||||
|
const state = usePlayer.getState();
|
||||||
|
const ni = state.nextTrack();
|
||||||
|
if (ni !== state.index || state.repeat === 2) {
|
||||||
|
setIndex(ni);
|
||||||
|
} else {
|
||||||
|
setPlaying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_sound = sound;
|
||||||
|
};
|
||||||
|
|
||||||
|
load().catch(console.error);
|
||||||
|
}, [currentTrack?.id]);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(async () => {
|
||||||
|
if (!_sound) return;
|
||||||
|
const status = await _sound.getStatusAsync();
|
||||||
|
if (!status.isLoaded) return;
|
||||||
|
if (status.isPlaying) {
|
||||||
|
await _sound.pauseAsync();
|
||||||
|
} else {
|
||||||
|
await _sound.playAsync();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seek = useCallback(async (seconds: number) => {
|
||||||
|
if (!_sound) return;
|
||||||
|
await _sound.setPositionAsync(seconds * 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
_sound?.unloadAsync().catch(() => {});
|
||||||
|
_sound = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { togglePlay, seek };
|
||||||
|
}
|
||||||
27
hooks/useAuth.ts
Normal file
27
hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { getAccessToken, getMe, clearTokens } from '../services/api';
|
||||||
|
import { useAuth } from '../store';
|
||||||
|
|
||||||
|
export function useBootAuth() {
|
||||||
|
const { setUser, setReady } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) { setReady(true); return; }
|
||||||
|
const me = await getMe();
|
||||||
|
setUser(me);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await clearTokens();
|
||||||
|
useAuth.getState().setUser(null);
|
||||||
|
}
|
||||||
56
main.yml
Normal file
56
main.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
name: Build iOS IPA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Expo prebuild
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
env:
|
||||||
|
EXPO_NO_DOTENV: 1
|
||||||
|
|
||||||
|
- name: Build unsigned archive
|
||||||
|
run: |
|
||||||
|
xcodebuild archive \
|
||||||
|
-workspace "ios/Burmalda Music.xcworkspace" \
|
||||||
|
-scheme "Burmalda Music" \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
ENABLE_BITCODE=NO \
|
||||||
|
| xcpretty || true
|
||||||
|
|
||||||
|
- name: Package IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p Payload
|
||||||
|
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/
|
||||||
|
zip -qr burmalda-unsigned.ipa Payload
|
||||||
|
rm -rf Payload
|
||||||
|
|
||||||
|
- name: Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: burmalda-unsigned-ipa
|
||||||
|
path: burmalda-unsigned.ipa
|
||||||
|
retention-days: 30
|
||||||
4
metro.config.js
Normal file
4
metro.config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
module.exports = config;
|
||||||
42
package.json
Normal file
42
package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "burmalda-music",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"build:ios": "eas build --platform ios --profile production",
|
||||||
|
"build:preview": "eas build --platform ios --profile preview",
|
||||||
|
"update": "eas update --branch production"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"expo": "~51.0.28",
|
||||||
|
"expo-router": "~3.5.23",
|
||||||
|
"expo-av": "~14.0.7",
|
||||||
|
"expo-blur": "~13.0.2",
|
||||||
|
"expo-constants": "~16.0.2",
|
||||||
|
"expo-haptics": "~13.0.1",
|
||||||
|
"expo-image": "~1.12.15",
|
||||||
|
"expo-linear-gradient": "~13.0.2",
|
||||||
|
"expo-linking": "~6.3.1",
|
||||||
|
"expo-secure-store": "~13.0.2",
|
||||||
|
"expo-status-bar": "~1.12.1",
|
||||||
|
"expo-updates": "~0.25.27",
|
||||||
|
"expo-web-browser": "~13.0.3",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-native": "0.74.5",
|
||||||
|
"react-native-gesture-handler": "~2.16.2",
|
||||||
|
"react-native-reanimated": "~3.10.1",
|
||||||
|
"react-native-safe-area-context": "4.10.5",
|
||||||
|
"react-native-screens": "3.31.1",
|
||||||
|
"zustand": "^4.5.4",
|
||||||
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
|
"@react-native-community/slider": "4.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.24.0",
|
||||||
|
"@types/react": "~18.2.79",
|
||||||
|
"@types/react-native": "~0.73.0",
|
||||||
|
"typescript": "~5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
125
services/api.ts
Normal file
125
services/api.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
const BASE = 'https://music.nodeshift.space';
|
||||||
|
const CLOUD = 'https://cloud.nodeshift.space';
|
||||||
|
const CLIENT_ID = 'nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U';
|
||||||
|
const REDIRECT = 'burmalda://auth/callback';
|
||||||
|
|
||||||
|
// ── Token storage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveTokens(access: string, refresh: string) {
|
||||||
|
await SecureStore.setItemAsync('access_token', access);
|
||||||
|
await SecureStore.setItemAsync('refresh_token', refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccessToken(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearTokens() {
|
||||||
|
await SecureStore.deleteItemAsync('access_token');
|
||||||
|
await SecureStore.deleteItemAsync('refresh_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth fetch helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(opts.headers as Record<string, string> || {}),
|
||||||
|
};
|
||||||
|
return fetch(`${BASE}${path}`, { ...opts, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildOAuthUrl(state: string): string {
|
||||||
|
const p = new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT,
|
||||||
|
state,
|
||||||
|
scope: 'profile email',
|
||||||
|
});
|
||||||
|
return `${CLOUD}/api/cloud/oauth/authorize?${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCode(code: string): Promise<{ access_token: string; refresh_token: string; user: User }> {
|
||||||
|
const res = await fetch(`${BASE}/api/auth/cloud-login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, redirect_uri: REDIRECT }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Auth failed');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
duration: number;
|
||||||
|
cover_url: string | null;
|
||||||
|
stream_url: string;
|
||||||
|
album?: string;
|
||||||
|
plays?: number;
|
||||||
|
liked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Album {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
cover_url: string | null;
|
||||||
|
track_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
username: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
plan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API calls ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTracks(): Promise<Track[]> {
|
||||||
|
const r = await apiFetch('/api/music/tracks');
|
||||||
|
if (!r.ok) return [];
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbums(): Promise<Album[]> {
|
||||||
|
const r = await apiFetch('/api/music/albums');
|
||||||
|
if (!r.ok) return [];
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<User | null> {
|
||||||
|
const r = await apiFetch('/api/music/profile/me');
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchTracks(q: string): Promise<Track[]> {
|
||||||
|
const r = await apiFetch(`/api/music/tracks?q=${encodeURIComponent(q)}`);
|
||||||
|
if (!r.ok) return [];
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function streamUrl(filename: string): string {
|
||||||
|
return `${BASE}/api/music/stream/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverUrl(filename: string | null): string | null {
|
||||||
|
if (!filename) return null;
|
||||||
|
if (filename.startsWith('http')) return filename;
|
||||||
|
return `${BASE}/api/music/cover/${filename}`;
|
||||||
|
}
|
||||||
73
store/index.ts
Normal file
73
store/index.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { Track, User } from '../services/api';
|
||||||
|
|
||||||
|
interface PlayerState {
|
||||||
|
queue: Track[];
|
||||||
|
index: number;
|
||||||
|
playing: boolean;
|
||||||
|
progress: number;
|
||||||
|
duration: number;
|
||||||
|
repeat: 0 | 1 | 2; // 0=off 1=all 2=one
|
||||||
|
shuffle: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
|
||||||
|
setQueue: (tracks: Track[], index?: number) => void;
|
||||||
|
setIndex: (i: number) => void;
|
||||||
|
setPlaying: (v: boolean) => void;
|
||||||
|
setProgress:(v: number) => void;
|
||||||
|
setDuration:(v: number) => void;
|
||||||
|
cycleRepeat:() => void;
|
||||||
|
toggleShuffle: () => void;
|
||||||
|
setExpanded:(v: boolean) => void;
|
||||||
|
nextTrack: () => number;
|
||||||
|
prevTrack: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlayer = create<PlayerState>((set, get) => ({
|
||||||
|
queue: [],
|
||||||
|
index: -1,
|
||||||
|
playing: false,
|
||||||
|
progress: 0,
|
||||||
|
duration: 0,
|
||||||
|
repeat: 0,
|
||||||
|
shuffle: false,
|
||||||
|
expanded: false,
|
||||||
|
|
||||||
|
setQueue: (tracks, index = 0) => set({ queue: tracks, index }),
|
||||||
|
setIndex: (index) => set({ index }),
|
||||||
|
setPlaying: (playing) => set({ playing }),
|
||||||
|
setProgress: (progress) => set({ progress }),
|
||||||
|
setDuration: (duration) => set({ duration }),
|
||||||
|
cycleRepeat: () => set((s) => ({ repeat: ((s.repeat + 1) % 3) as 0 | 1 | 2 })),
|
||||||
|
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
|
||||||
|
setExpanded: (expanded) => set({ expanded }),
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
}));
|
||||||
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
workflows/build-ios.yml
Normal file
56
workflows/build-ios.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
name: Build iOS IPA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Expo prebuild
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
env:
|
||||||
|
EXPO_NO_DOTENV: 1
|
||||||
|
|
||||||
|
- name: Build unsigned archive
|
||||||
|
run: |
|
||||||
|
xcodebuild archive \
|
||||||
|
-workspace "ios/Burmalda Music.xcworkspace" \
|
||||||
|
-scheme "Burmalda Music" \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
ENABLE_BITCODE=NO \
|
||||||
|
| xcpretty || true
|
||||||
|
|
||||||
|
- name: Package IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p Payload
|
||||||
|
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/
|
||||||
|
zip -qr burmalda-unsigned.ipa Payload
|
||||||
|
rm -rf Payload
|
||||||
|
|
||||||
|
- name: Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: burmalda-unsigned-ipa
|
||||||
|
path: burmalda-unsigned.ipa
|
||||||
|
retention-days: 30
|
||||||
56
workflows/main.yml
Normal file
56
workflows/main.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
name: Build iOS IPA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Expo prebuild
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
env:
|
||||||
|
EXPO_NO_DOTENV: 1
|
||||||
|
|
||||||
|
- name: Build unsigned archive
|
||||||
|
run: |
|
||||||
|
xcodebuild archive \
|
||||||
|
-workspace "ios/Burmalda Music.xcworkspace" \
|
||||||
|
-scheme "Burmalda Music" \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
ENABLE_BITCODE=NO \
|
||||||
|
| xcpretty || true
|
||||||
|
|
||||||
|
- name: Package IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p Payload
|
||||||
|
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/
|
||||||
|
zip -qr burmalda-unsigned.ipa Payload
|
||||||
|
rm -rf Payload
|
||||||
|
|
||||||
|
- name: Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: burmalda-unsigned-ipa
|
||||||
|
path: burmalda-unsigned.ipa
|
||||||
|
retention-days: 30
|
||||||
Loading…
Add table
Add a link
Reference in a new issue