From 675493bafcb4fdfcd949377487f683f1877dc275 Mon Sep 17 00:00:00 2001 From: Savsis Date: Mon, 27 Apr 2026 21:01:17 +0500 Subject: [PATCH] Add files via upload --- README.md | 95 ++++++++++++ app.json | 57 +++++++ app/(tabs)/_layout.tsx | 95 ++++++++++++ app/(tabs)/index.tsx | 172 +++++++++++++++++++++ app/(tabs)/library.tsx | 167 ++++++++++++++++++++ app/(tabs)/profile.tsx | 125 +++++++++++++++ app/(tabs)/search.tsx | 136 +++++++++++++++++ app/_layout.tsx | 39 +++++ app/auth.tsx | 163 ++++++++++++++++++++ assets/icon.png | Bin 0 -> 11968 bytes assets/placeholder.png | Bin 0 -> 207 bytes assets/splash.png | Bin 0 -> 4310 bytes babel.config.js | 7 + build-ios.yml | 56 +++++++ components/MiniPlayer.tsx | 141 +++++++++++++++++ components/Player.tsx | 309 ++++++++++++++++++++++++++++++++++++++ constants/theme.ts | 43 ++++++ eas.json | 26 ++++ hooks/useAudio.ts | 96 ++++++++++++ hooks/useAuth.ts | 27 ++++ main.yml | 56 +++++++ metro.config.js | 4 + package.json | 42 ++++++ services/api.ts | 125 +++++++++++++++ store/index.ts | 73 +++++++++ tsconfig.json | 10 ++ workflows/build-ios.yml | 56 +++++++ workflows/main.yml | 56 +++++++ 28 files changed, 2176 insertions(+) create mode 100644 README.md create mode 100644 app.json create mode 100644 app/(tabs)/_layout.tsx create mode 100644 app/(tabs)/index.tsx create mode 100644 app/(tabs)/library.tsx create mode 100644 app/(tabs)/profile.tsx create mode 100644 app/(tabs)/search.tsx create mode 100644 app/_layout.tsx create mode 100644 app/auth.tsx create mode 100644 assets/icon.png create mode 100644 assets/placeholder.png create mode 100644 assets/splash.png create mode 100644 babel.config.js create mode 100644 build-ios.yml create mode 100644 components/MiniPlayer.tsx create mode 100644 components/Player.tsx create mode 100644 constants/theme.ts create mode 100644 eas.json create mode 100644 hooks/useAudio.ts create mode 100644 hooks/useAuth.ts create mode 100644 main.yml create mode 100644 metro.config.js create mode 100644 package.json create mode 100644 services/api.ts create mode 100644 store/index.ts create mode 100644 tsconfig.json create mode 100644 workflows/build-ios.yml create mode 100644 workflows/main.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..56fc436 --- /dev/null +++ b/README.md @@ -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 — Цвета, шрифты, отступы +``` diff --git a/app.json b/app.json new file mode 100644 index 0000000..2d27328 --- /dev/null +++ b/app.json @@ -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 + } + } +} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..75356f7 --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -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 ( + + ( + + ), + tabBarActiveTintColor: Colors.green, + tabBarInactiveTintColor: Colors.text3, + tabBarLabelStyle: { fontSize: 10, fontWeight: '600', marginBottom: 4 }, + tabBarItemStyle: { paddingTop: 8 }, + }} + > + }} /> + }} /> + }} /> + }} /> + + + + {expanded && } + + ); +} + +function HomeIcon({ color }: { color: string }) { + return ( + + + + + ); +} + +function LibIcon({ color }: { color: string }) { + return ( + + + + + + ); +} + +function SearchIcon({ color }: { color: string }) { + return ( + + + + + ); +} + +function ProfileIcon({ color }: { color: string }) { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + tabBar: { + position: 'absolute', + borderTopWidth: 0, + backgroundColor: 'transparent', + elevation: 0, + height: TAB_BAR_HEIGHT, + }, +}); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..03c1f88 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -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([]); + const [albums, setAlbums] = useState([]); + 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 ( + + } + showsVerticalScrollIndicator={false} + > + {/* Header */} + + Добро пожаловать + NodeShift Music + + + {/* Albums row */} + {albums.length > 0 && ( + + Альбомы + + {albums.map((a, i) => ( + {/* open album */}} /> + ))} + + + )} + + {/* Tracks list */} + {tracks.length > 0 && ( + + Треки + {tracks.map((t, i) => ( + playTrack(t, i)} /> + ))} + + )} + + ); +} + +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 ( + + { scale.value = withSpring(0.95, { damping: 15 }); }} + onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} + onPress={onPress} + style={styles.albumCard} + > + + {album.title} + {album.artist} + + + ); +} + +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 ( + + { scale.value = withSpring(0.97, { damping: 15 }); }} + onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} + onPress={onPress} + style={[styles.trackRow, isPlaying && styles.trackRowActive]} + > + + + + {track.title} + + {track.artist} + + {fmt(track.duration)} + + + ); +} + +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 }, +}); diff --git a/app/(tabs)/library.tsx b/app/(tabs)/library.tsx new file mode 100644 index 0000000..dee5f99 --- /dev/null +++ b/app/(tabs)/library.tsx @@ -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([]); + const [filtered, setFiltered] = useState([]); + 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 ( + + + Библиотека + {all.length} треков + + + {/* Search */} + + + + {!!query && ( + setQuery('')} hitSlop={12}> + ✕ + + )} + + + String(t.id)} + contentContainerStyle={{ + paddingHorizontal: Spacing.md, + paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24, + }} + showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => ( + playTrack(item, index)} /> + )} + ItemSeparatorComponent={() => } + /> + + ); +} + +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 ( + + { scale.value = withSpring(0.97, { damping: 15 }); }} + onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} + onPress={onPress} + style={[styles.row, active && styles.rowActive]} + > + + {active + ? {[0,1,2].map(i => )} + : {index + 1}} + + + + + {track.title} + + {track.artist} + + {fmt(track.duration)} + + + ); +} + +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 ; +} + +function SearchSvg() { + return ( + + + + + ); +} + +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 }, +}); diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx new file mode 100644 index 0000000..c947eca --- /dev/null +++ b/app/(tabs)/profile.tsx @@ -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 ( + + Профиль + + + + {user.avatar_url ? ( + + ) : ( + + {initials} + + )} + {user.username || 'Пользователь'} + {user.email} + + + {user.plan === 'free' ? 'Free' : 'Pro'} + + + + + + + Аккаунт + + + + + + + + + + + + + Выйти из аккаунта + + + + + Burmalda Music · v1.0.0 · by NodeShift + + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +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' }, +}); diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx new file mode 100644 index 0000000..419a1ea --- /dev/null +++ b/app/(tabs)/search.tsx @@ -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([]); + 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 ( + + Поиск + + + { setQuery(q); doSearch(q); }} + returnKeyType="search" + onSubmitEditing={() => doSearch(query)} + autoCorrect={false} + clearButtonMode="while-editing" + /> + + + {!searched && ( + + 🎵 + Введите название трека{'\n'}или имя исполнителя + + )} + + {searched && !loading && results.length === 0 && ( + + 🔍 + Ничего не найдено + + )} + + String(t.id)} + keyboardShouldPersistTaps="handled" + contentContainerStyle={{ + paddingHorizontal: Spacing.md, + paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24, + }} + showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => ( + playTrack(index)} /> + )} + /> + + ); +} + +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 ( + + { scale.value = withSpring(0.97, { damping: 15 }); }} + onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} + onPress={onPress} + style={[styles.row, active && styles.rowActive]} + > + + + + {track.title} + + {track.artist} + + {fmt(track.duration)} + + + ); +} + +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 }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..28bb69b --- /dev/null +++ b/app/_layout.tsx @@ -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 ( + + + + + + + + ); +} + +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 + } + })(); + }, []); +} diff --git a/app/auth.tsx b/app/auth.tsx new file mode 100644 index 0000000..555fa19 --- /dev/null +++ b/app/auth.tsx @@ -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 ( + + + + + + + + + + + + + + + Burmalda Music + by NodeShift + + + + + { btnScale.value = withSpring(0.96, { damping: 14 }); }} + onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }} + onPress={handleLogin} + > + + {loading + ? + : ( + + + + + + )} + + {loading ? 'Ожидаем авторизацию…' : 'Войти через NodeShift'} + + + + + + {!!error && ( + {error} + )} + + + + Нажимая «Войти», вы соглашаетесь с{'\n'}условиями использования NodeShift + + + + ); +} + +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 ( + + ); +} + +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 }, +}); diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b047c145da55fe539243924f758a38fca2e9b2a9 GIT binary patch literal 11968 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 M%Js|H7mc6%A2*P?y8r+H literal 0 HcmV?d00001 diff --git a/assets/placeholder.png b/assets/placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..ea048728c1257d7bd310d929d03969c1ec060e2c GIT binary patch literal 207 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGJMX`U{QAr*7pUNGckFyJ|`K}oZI znl_L8>4)~G-n{#L^993NMk|Nx2EGeY3z)aC(TmHx|MWzg$I5ekfG%Y4boFyt=akR{ E07F(qNB{r; literal 0 HcmV?d00001 diff --git a/assets/splash.png b/assets/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..38a2a7a6c17c5f02469457bec3bc6d480f095d31 GIT binary patch literal 4310 zcmeAS@N?(olHy`uVBq!ia0y~yU;;8(7&t(b#Hq6B3=9HuJzX3_D(1YswvdrQf#<-6 zmw*53*Dx*;{5W;)U0Vi+(|`CF815MZ6{avSD4bwmaG1cz(4fG~z~IQnz|h14G)|I% zfx~E2!Du*)riamdFV#E@o1}fv{g)At61+o XYwJv{^oKuz;}r~^u6{1-oD!M<`4t { + 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 ( + + + + + + + {/* Cover */} + + + {/* Info */} + + {track.title} + {track.artist} + + + {/* Play/Pause */} + + {playing ? : } + + + {/* Next */} + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const next = usePlayer.getState().nextTrack(); + usePlayer.getState().setIndex(next); + }} + hitSlop={12} + > + + + + + {/* Progress line */} + + + + ); +} + +function ProgressLine() { + const { progress, duration } = usePlayer(); + const pct = duration > 0 ? (progress / duration) * 100 : 0; + return ( + + + + ); +} + +function PlayIcon() { return ; } +function PauseIcon() { return ; } +function NextIcon() { return ; } + +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 }, +}); diff --git a/components/Player.tsx b/components/Player.tsx new file mode 100644 index 0000000..e0a3b8c --- /dev/null +++ b/components/Player.tsx @@ -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({ + 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 ( + + {/* Blurred background art */} + + + + + + {/* Handle */} + + + + + {/* Header */} + + Сейчас играет + + + {/* Cover */} + + + + + + + {/* Info + like */} + + + {track.title} + {track.artist} + + + + + {/* Progress bar */} + + seek(v * duration)} + /> + + {fmt(progress)} + {fmt(duration)} + + + + {/* Controls */} + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}> + + + + + + + + + {playing ? : } + + + + + + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}> + + + + + + + ); +} + +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 ( + + { scale.value = withSpring(0.82, { damping: 12 }); }} + onPressOut={() => { scale.value = withSpring(1, { damping: 12 }); }} + hitSlop={12} + > + {children} + + + ); +} + +function LikeButton() { + const scale = useSharedValue(1); + const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); + return ( + + { + scale.value = withSpring(1.35, { damping: 7 }, () => { + scale.value = withSpring(1, { damping: 12 }); + }); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + > + + + + + + ); +} + +function PlayIcon() { + return ; +} +function PauseIcon() { + return ; +} +function PrevIcon() { + return ( + + + + + ); +} +function NextIcon() { + return ( + + + + + ); +} +function ShuffleIcon({ active }: { active: boolean }) { + const c = active ? Colors.green : Colors.text2; + return ( + + + + + + + ); +} +function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) { + const c = repeat > 0 ? Colors.green : Colors.text2; + return ( + + + + + + + + {repeat === 2 && ( + + 1 + + )} + + ); +} + +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' }, +}); diff --git a/constants/theme.ts b/constants/theme.ts new file mode 100644 index 0000000..6f3bb39 --- /dev/null +++ b/constants/theme.ts @@ -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; diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..accd87f --- /dev/null +++ b/eas.json @@ -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": {} + } +} diff --git a/hooks/useAudio.ts b/hooks/useAudio.ts new file mode 100644 index 0000000..77f6d46 --- /dev/null +++ b/hooks/useAudio.ts @@ -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 }; +} diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts new file mode 100644 index 0000000..45d2aa9 --- /dev/null +++ b/hooks/useAuth.ts @@ -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); +} diff --git a/main.yml b/main.yml new file mode 100644 index 0000000..7f38455 --- /dev/null +++ b/main.yml @@ -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 diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..24f7644 --- /dev/null +++ b/metro.config.js @@ -0,0 +1,4 @@ +const { getDefaultConfig } = require('expo/metro-config'); + +const config = getDefaultConfig(__dirname); +module.exports = config; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f95a331 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/services/api.ts b/services/api.ts new file mode 100644 index 0000000..98808a3 --- /dev/null +++ b/services/api.ts @@ -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 { + 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 { + const token = await getAccessToken(); + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(opts.headers as Record || {}), + }; + 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 { + const r = await apiFetch('/api/music/tracks'); + if (!r.ok) return []; + return r.json(); +} + +export async function getAlbums(): Promise { + const r = await apiFetch('/api/music/albums'); + if (!r.ok) return []; + return r.json(); +} + +export async function getMe(): Promise { + const r = await apiFetch('/api/music/profile/me'); + if (!r.ok) return null; + return r.json(); +} + +export async function searchTracks(q: string): Promise { + 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}`; +} diff --git a/store/index.ts b/store/index.ts new file mode 100644 index 0000000..186723c --- /dev/null +++ b/store/index.ts @@ -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((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((set) => ({ + user: null, + ready: false, + setUser: (user) => set({ user }), + setReady:(ready) => set({ ready }), +})); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..84d97cb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/workflows/build-ios.yml b/workflows/build-ios.yml new file mode 100644 index 0000000..7f38455 --- /dev/null +++ b/workflows/build-ios.yml @@ -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 diff --git a/workflows/main.yml b/workflows/main.yml new file mode 100644 index 0000000..7f38455 --- /dev/null +++ b/workflows/main.yml @@ -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