From 5748d2d13c581cb42cca57e3469351ba89acb1ba Mon Sep 17 00:00:00 2001 From: Savsis Date: Tue, 5 May 2026 18:17:09 +0500 Subject: [PATCH] Add files via upload --- app.json | 117 ++--- app/(tabs)/_layout.tsx | 222 +++++----- app/(tabs)/index.tsx | 501 ++++++++++++--------- app/(tabs)/library.tsx | 280 ++++++------ app/(tabs)/liked.tsx | 244 +++++------ app/(tabs)/profile.tsx | 250 +++++------ app/(tabs)/search.tsx | 260 +++++------ app/_layout.tsx | 80 ++-- app/auth.tsx | 330 +++++++------- babel.config.js | 14 +- build-ios.yml | 112 ++--- build.yml | 85 ++++ components/CoverImage.tsx | 146 +++---- components/DownloadButton.tsx | 101 +++++ components/MiniPlayer.tsx | 258 +++++------ components/Player.tsx | 796 ++++++++++++++++++++-------------- constants/theme.ts | 96 ++-- eas.json | 52 +-- hooks/PlaybackService.ts | 110 ++--- hooks/useAudio.ts | 214 ++++----- hooks/useAuth.ts | 116 ++--- index.js | 10 +- main.yml | 112 ++--- metro.config.js | 8 +- package.json | 86 ++-- services/api.ts | 373 ++++++++-------- services/offline.ts | 193 +++++++++ store/index.ts | 279 +++++++----- tsconfig.json | 20 +- workflows/build-ios.yml | 112 ++--- workflows/main.yml | 112 ++--- 31 files changed, 3197 insertions(+), 2492 deletions(-) create mode 100644 build.yml create mode 100644 components/DownloadButton.tsx create mode 100644 services/offline.ts diff --git a/app.json b/app.json index 9dc9658..f02f35b 100644 --- a/app.json +++ b/app.json @@ -1,58 +1,59 @@ -{ - "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", - "react-native-track-player", - [ - "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 - } - } -} +{ + "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": "Не используется", + "NSFileProtectionComplete": "NSFileProtectionCompleteUnlessOpen", + "CFBundleURLTypes": [ + { + "CFBundleURLSchemes": ["burmalda"] + } + ] + } + }, + "scheme": "burmalda", + "plugins": [ + "expo-router", + "react-native-track-player", + [ + "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 index 65f5986..27908b6 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,111 +1,111 @@ -import { useEffect } from 'react'; -import { View, StyleSheet, ActivityIndicator } 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, useLikes } from '../../store'; -import { useAudio } from '../../hooks/useAudio'; -import MiniPlayer from '../../components/MiniPlayer'; -import Player from '../../components/Player'; - -export default function TabsLayout() { - const { user, ready } = useAuth(); - const { expanded } = usePlayer(); - const { loadLikes } = useLikes(); - useAudio(); // single audio controller for the whole app - - useEffect(() => { - if (ready && !user) router.replace('/auth'); - }, [user, ready]); - - useEffect(() => { - if (user) loadLikes(); - }, [user]); - - if (!ready) return ( - - - - ); - if (!user) return null; - - return ( - - ( - - ), - tabBarActiveTintColor: Colors.text, - tabBarInactiveTintColor: Colors.text3, - tabBarLabelStyle: { fontSize: 9, fontWeight: '500', letterSpacing: 0.3, marginBottom: 4 }, - tabBarItemStyle: { paddingTop: 6 }, - }} - > - }} /> - }} /> - }} /> - }} /> - }} /> - - - - {expanded && } - - ); -} - -function HomeIcon({ color }: { color: string }) { - return ( - - - - - ); -} -function LibIcon({ color }: { color: string }) { - return ( - - - - - - ); -} -function SearchIcon({ color }: { color: string }) { - return ( - - - - - ); -} -function HeartIcon({ color }: { color: string }) { - return ( - - - - ); -} -function ProfileIcon({ color }: { color: string }) { - return ( - - - - - ); -} - -const styles = StyleSheet.create({ - tabBar: { - position: 'absolute', - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: 'rgba(255,255,255,0.08)', - backgroundColor: 'transparent', - elevation: 0, - height: TAB_BAR_HEIGHT, - }, -}); +import { useEffect } from 'react'; +import { View, StyleSheet, ActivityIndicator } 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, useLikes } from '../../store'; +import { useAudio } from '../../hooks/useAudio'; +import MiniPlayer from '../../components/MiniPlayer'; +import Player from '../../components/Player'; + +export default function TabsLayout() { + const { user, ready } = useAuth(); + const { expanded } = usePlayer(); + const { loadLikes } = useLikes(); + useAudio(); // single audio controller for the whole app + + useEffect(() => { + if (ready && !user) router.replace('/auth'); + }, [user, ready]); + + useEffect(() => { + if (user) loadLikes(); + }, [user]); + + if (!ready) return ( + + + + ); + if (!user) return null; + + return ( + + ( + + ), + tabBarActiveTintColor: Colors.text, + tabBarInactiveTintColor: Colors.text3, + tabBarLabelStyle: { fontSize: 9, fontWeight: '500', letterSpacing: 0.3, marginBottom: 4 }, + tabBarItemStyle: { paddingTop: 6 }, + }} + > + }} /> + }} /> + }} /> + }} /> + }} /> + + + + {expanded && } + + ); +} + +function HomeIcon({ color }: { color: string }) { + return ( + + + + + ); +} +function LibIcon({ color }: { color: string }) { + return ( + + + + + + ); +} +function SearchIcon({ color }: { color: string }) { + return ( + + + + + ); +} +function HeartIcon({ color }: { color: string }) { + return ( + + + + ); +} +function ProfileIcon({ color }: { color: string }) { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + tabBar: { + position: 'absolute', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(255,255,255,0.08)', + backgroundColor: 'transparent', + elevation: 0, + height: TAB_BAR_HEIGHT, + }, +}); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 2b3305e..d425b73 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,211 +1,290 @@ -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 Svg, { Path } from 'react-native-svg'; -import * as Haptics from 'expo-haptics'; -import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; -import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api'; -import { usePlayer, useLikes } from '../../store'; -import CoverImage from '../../components/CoverImage'; - -const { width } = Dimensions.get('window'); -const CARD_W = (width - Spacing.md * 3) / 2; - -export default function HomeScreen() { - const [tracks, setTracks] = useState([]); - const [albums, setAlbums] = useState([]); - const [refresh, setRefresh] = useState(false); - const insets = useSafeAreaInsets(); - const { setQueue } = usePlayer(); - const { toggleLike, isLiked } = useLikes(); - - 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, list: Track[], idx: number) => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - setQueue(list, idx); - usePlayer.getState().setExpanded(true); - }; - - return ( - } - showsVerticalScrollIndicator={false} - > - - NodeShift Music - - - - - {albums.length > 0 && ( - - Альбомы - - {albums.map((a, i) => ( - { - const albumTracks = tracks.filter(t => t.album === a.title); - if (albumTracks.length > 0) { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - setQueue(albumTracks, 0); - usePlayer.getState().setExpanded(true); - } - }} /> - ))} - - - )} - - {tracks.length > 0 && ( - - Треки - {tracks.map((t, i) => ( - playTrack(t, tracks, i)} - onLike={() => toggleLike(t.id)} - /> - ))} - - )} - - ); -} - -function NowPlayingStrip() { - const { queue, index, playing, setExpanded } = usePlayer(); - const track = queue[index]; - if (!track) return null; - const cover = coverUrl(track.cover_url); - - return ( - - {/* Blurred cover background */} - - - setExpanded(true)}> - - - Сейчас играет - {track.title} - {track.artist} - - - {playing - ? {[1, 2, 3].map(i => )} - : - } - - - - ); -} - -function AlbumCard({ album, delay, onPress }: { album: Album; delay: number; onPress: () => void }) { - const scale = useSharedValue(1); - const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); - const cover = coverUrl(album.cover_url); - - return ( - - { scale.value = withSpring(0.95, { damping: 15 }); }} - onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} - onPress={onPress} - style={styles.albumCard} - > - - {album.title} - {album.artist} - {album.track_count} треков - - - ); -} - -function TrackRow({ track, index, liked, onPress, onLike }: { - track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void; -}) { - const { queue, index: qi } = usePlayer(); - const active = queue[qi]?.id === track.id; - const cover = coverUrl(track.cover_url); - - return ( - - {index + 1} - - - {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({ - scroll: { flex: 1, backgroundColor: Colors.bg }, - content: { paddingHorizontal: Spacing.md }, - pageHeader: { marginBottom: Spacing.md }, - pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 }, - section: { marginBottom: Spacing.xl }, - secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md }, - hRow: { gap: Spacing.md, paddingRight: Spacing.md }, - albumCard: { width: CARD_W }, - albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' }, - albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 }, - albumCount: { color: Colors.text3, fontSize: Font.xs, marginTop: 2 }, - trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: Spacing.sm }, - trackRowActive: { backgroundColor: Colors.surface }, - trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, - trackInfo: { flex: 1 }, - trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, - trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, - trackDur: { color: Colors.text3, fontSize: Font.xs }, - // Now playing strip - npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 }, - npDim: { backgroundColor: 'rgba(0,0,0,0.5)' }, - npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 }, - npInfo: { flex: 1 }, - npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 }, - npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, - npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 }, - npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, - npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 }, - npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 }, -}); +import { useEffect, useState, useCallback } from 'react'; +import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl, ActivityIndicator } from 'react-native'; +import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useFocusEffect } from 'expo-router'; +import * as Network from 'expo-network'; +import Svg, { Path } from 'react-native-svg'; +import * as Haptics from 'expo-haptics'; +import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; +import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api'; +import { downloadTrack, deleteTrack, isTrackDownloaded, DownloadProgress, cacheTracksAndAlbums, getCachedTracks, getCachedAlbums, initOfflineStorage } from '../../services/offline'; +import { usePlayer, useLikes, useOffline } from '../../store'; +import CoverImage from '../../components/CoverImage'; +import DownloadButton from '../../components/DownloadButton'; + +const { width } = Dimensions.get('window'); +const CARD_W = (width - Spacing.md * 3) / 2; + +export default function HomeScreen() { + const [tracks, setTracks] = useState([]); + const [albums, setAlbums] = useState([]); + const [refresh, setRefresh] = useState(false); + const [loading, setLoading] = useState(true); + const insets = useSafeAreaInsets(); + const { setQueue } = usePlayer(); + const { toggleLike, isLiked } = useLikes(); + const { setOnline, loadDownloadedTracks } = useOffline(); + + const load = useCallback(async () => { + // Check network + const isOnline = await Network.getNetworkStateAsync(); + setOnline(isOnline.isConnected); + + if (isOnline.isConnected) { + try { + const [t, a] = await Promise.all([getTracks(), getAlbums()]); + if (t.length > 0) { + setTracks(t); + setAlbums(a); + await cacheTracksAndAlbums(t, a); + setLoading(false); + return; + } + } catch (err) { + console.warn('Failed to fetch online:', err); + } + } + + // Fallback to cached data + const cachedTracks = await getCachedTracks(); + const cachedAlbums = await getCachedAlbums(); + if (cachedTracks) setTracks(cachedTracks); + if (cachedAlbums) setAlbums(cachedAlbums); + setLoading(false); + }, [setOnline]); + + useEffect(() => { + initOfflineStorage(); + loadDownloadedTracks(); + load(); + }, []); + + useFocusEffect(() => { + loadDownloadedTracks(); + }); + + const onRefresh = async () => { + setRefresh(true); + await load(); + setRefresh(false); + }; + + const playTrack = (track: Track, list: Track[], idx: number) => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setQueue(list, idx); + usePlayer.getState().setExpanded(true); + }; + + if (loading && tracks.length === 0) { + return ( + + + + ); + } + + return ( + } + showsVerticalScrollIndicator={false} + > + + NodeShift Music + + + + + {albums.length > 0 && ( + + Альбомы + + {albums.map((a, i) => ( + { + const albumTracks = tracks.filter(t => t.album === a.title); + if (albumTracks.length > 0) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setQueue(albumTracks, 0); + usePlayer.getState().setExpanded(true); + } + }} /> + ))} + + + )} + + {tracks.length > 0 && ( + + Треки + {tracks.map((t, i) => ( + playTrack(t, tracks, i)} + onLike={() => toggleLike(t.id)} + /> + ))} + + )} + + ); +} + +function NowPlayingStrip() { + const { queue, index, playing, setExpanded } = usePlayer(); + const track = queue[index]; + if (!track) return null; + const cover = coverUrl(track.cover_url); + + return ( + + {/* Blurred cover background */} + + + setExpanded(true)}> + + + Сейчас играет + {track.title} + {track.artist} + + + {playing + ? {[1, 2, 3].map(i => )} + : + } + + + + ); +} + +function AlbumCard({ album, delay, onPress }: { album: Album; delay: number; onPress: () => void }) { + const scale = useSharedValue(1); + const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); + const cover = coverUrl(album.cover_url); + + return ( + + { scale.value = withSpring(0.95, { damping: 15 }); }} + onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }} + onPress={onPress} + style={styles.albumCard} + > + + {album.title} + {album.artist} + {album.track_count} треков + + + ); +} + +function TrackRow({ track, index, liked, onPress, onLike }: { + track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void; +}) { + const { queue, index: qi } = usePlayer(); + const { downloadedTracks, downloading, addDownloadedTrack, removeDownloadedTrack, setDownloading } = useOffline(); + const [isDownloaded, setIsDownloaded] = useState(false); + const active = queue[qi]?.id === track.id; + const cover = coverUrl(track.cover_url); + const downloadProgress = downloading.get(track.id) || 0; + + useEffect(() => { + setIsDownloaded(downloadedTracks.has(track.id)); + }, [downloadedTracks, track.id]); + + const handleDownload = async () => { + if (isDownloaded) { + await deleteTrack(track.id); + removeDownloadedTrack(track.id); + setIsDownloaded(false); + } else { + try { + const success = await downloadTrack(track, (prog) => { + setDownloading(track.id, prog.progress); + }); + if (success) { + addDownloadedTrack(track.id); + setIsDownloaded(true); + } + } catch (err) { + console.warn('Download failed:', err); + } + } + }; + + return ( + + {index + 1} + + + {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({ + scroll: { flex: 1, backgroundColor: Colors.bg }, + content: { paddingHorizontal: Spacing.md }, + pageHeader: { marginBottom: Spacing.md }, + pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 }, + section: { marginBottom: Spacing.xl }, + secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md }, + hRow: { gap: Spacing.md, paddingRight: Spacing.md }, + albumCard: { width: CARD_W }, + albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' }, + albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 }, + albumCount: { color: Colors.text3, fontSize: Font.xs, marginTop: 2 }, + trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: 8 }, + trackRowActive: { backgroundColor: Colors.surface }, + trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, + trackInfo: { flex: 1 }, + trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, + trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, + trackDur: { color: Colors.text3, fontSize: Font.xs, width: 36, textAlign: 'right' }, + // Now playing strip + npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 }, + npDim: { backgroundColor: 'rgba(0,0,0,0.5)' }, + npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 }, + npInfo: { flex: 1 }, + npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 }, + npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, + npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 }, + npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 }, + npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 }, +}); diff --git a/app/(tabs)/library.tsx b/app/(tabs)/library.tsx index 13e8429..ad9a2b8 100644 --- a/app/(tabs)/library.tsx +++ b/app/(tabs)/library.tsx @@ -1,140 +1,140 @@ -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, Path } from 'react-native-svg'; -import * as Haptics from 'expo-haptics'; -import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; -import { getTracks, Track, coverUrl } from '../../services/api'; -import { usePlayer, useLikes } from '../../store'; - -export default function LibraryScreen() { - const [all, setAll] = useState([]); - const [filtered, setFiltered] = useState([]); - const [query, setQuery] = useState(''); - const insets = useSafeAreaInsets(); - const { setQueue } = usePlayer(); - const { toggleLike, isLiked } = useLikes(); - - 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 = (idx: number) => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - setQueue(filtered, idx); - usePlayer.getState().setExpanded(true); - }; - - return ( - - {/* Header */} - - Библиотека - {all.length} треков - - - {/* Search */} - - - - - - - {!!query && ( - setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}> - ✕ - - )} - - - String(t.id)} - contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} - showsVerticalScrollIndicator={false} - renderItem={({ item, index }) => ( - playTrack(index)} - onLike={() => toggleLike(item.id)} - /> - )} - /> - - ); -} - -function TrackItem({ track, index, liked, onPress, onLike }: { - track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void; -}) { - const { queue, index: qi } = usePlayer(); - const active = queue[qi]?.id === track.id; - const cover = coverUrl(track.cover_url); - - return ( - - - {index + 1} - - - {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 }, - header: { flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm }, - title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 }, - count: { color: Colors.text3, fontSize: Font.sm }, - searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: 8 }, - searchInput:{ flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11, paddingRight: Spacing.md }, - row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: Spacing.sm, borderRadius: Radius.sm, paddingHorizontal: 4 }, - rowActive: { backgroundColor: Colors.surface }, - num: { width: 22, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, - 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 }, -}); +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, Path } from 'react-native-svg'; +import * as Haptics from 'expo-haptics'; +import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; +import { getTracks, Track, coverUrl } from '../../services/api'; +import { usePlayer, useLikes } from '../../store'; + +export default function LibraryScreen() { + const [all, setAll] = useState([]); + const [filtered, setFiltered] = useState([]); + const [query, setQuery] = useState(''); + const insets = useSafeAreaInsets(); + const { setQueue } = usePlayer(); + const { toggleLike, isLiked } = useLikes(); + + 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 = (idx: number) => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setQueue(filtered, idx); + usePlayer.getState().setExpanded(true); + }; + + return ( + + {/* Header */} + + Библиотека + {all.length} треков + + + {/* Search */} + + + + + + + {!!query && ( + setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}> + ✕ + + )} + + + String(t.id)} + contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} + showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => ( + playTrack(index)} + onLike={() => toggleLike(item.id)} + /> + )} + /> + + ); +} + +function TrackItem({ track, index, liked, onPress, onLike }: { + track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void; +}) { + const { queue, index: qi } = usePlayer(); + const active = queue[qi]?.id === track.id; + const cover = coverUrl(track.cover_url); + + return ( + + + {index + 1} + + + {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 }, + header: { flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm }, + title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 }, + count: { color: Colors.text3, fontSize: Font.sm }, + searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: 8 }, + searchInput:{ flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11, paddingRight: Spacing.md }, + row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: Spacing.sm, borderRadius: Radius.sm, paddingHorizontal: 4 }, + rowActive: { backgroundColor: Colors.surface }, + num: { width: 22, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, + 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 }, +}); diff --git a/app/(tabs)/liked.tsx b/app/(tabs)/liked.tsx index ddf0c96..6be7a1e 100644 --- a/app/(tabs)/liked.tsx +++ b/app/(tabs)/liked.tsx @@ -1,122 +1,122 @@ -import { useEffect, useState } from 'react'; -import { View, Text, FlatList, StyleSheet, 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, { Path } 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, useLikes } from '../../store'; - -export default function LikedScreen() { - const [all, setAll] = useState([]); - const insets = useSafeAreaInsets(); - const { setQueue } = usePlayer(); - const { likes, toggleLike } = useLikes(); - - useEffect(() => { - getTracks().then(setAll); - }, []); - - const liked = all.filter(t => likes.has(t.id)); - - const playTrack = (track: Track, idx: number) => { - setQueue(liked, idx); - usePlayer.getState().setExpanded(true); - }; - - return ( - - - - - - Понравилось - {liked.length} треков - - - {liked.length === 0 ? ( - - ♡ - Пусто - Нажмите ♡ у трека, чтобы добавить его сюда - - ) : ( - String(t.id)} - contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} - showsVerticalScrollIndicator={false} - renderItem={({ item, index }) => ( - playTrack(item, index)} - onUnlike={() => toggleLike(item.id)} - /> - )} - /> - )} - - ); -} - -function LikedRow({ track, index, onPress, onUnlike }: { - track: Track; index: number; onPress: () => void; onUnlike: () => 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} - - - - - - - - - ); -} - -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: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.md }, - title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 }, - count: { color: Colors.text3, fontSize: Font.sm }, - empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 }, - emptyIcon: { fontSize: 52, color: Colors.text3, marginBottom: 16 }, - emptyTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: 8 }, - emptyText: { color: Colors.text2, fontSize: Font.sm, textAlign: 'center', lineHeight: 20 }, - 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 }, - info: { flex: 1 }, - trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, - artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, - likeBtn: { padding: 4 }, -}); +import { useEffect, useState } from 'react'; +import { View, Text, FlatList, StyleSheet, 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, { Path } 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, useLikes } from '../../store'; + +export default function LikedScreen() { + const [all, setAll] = useState([]); + const insets = useSafeAreaInsets(); + const { setQueue } = usePlayer(); + const { likes, toggleLike } = useLikes(); + + useEffect(() => { + getTracks().then(setAll); + }, []); + + const liked = all.filter(t => likes.has(t.id)); + + const playTrack = (track: Track, idx: number) => { + setQueue(liked, idx); + usePlayer.getState().setExpanded(true); + }; + + return ( + + + + + + Понравилось + {liked.length} треков + + + {liked.length === 0 ? ( + + ♡ + Пусто + Нажмите ♡ у трека, чтобы добавить его сюда + + ) : ( + String(t.id)} + contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} + showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => ( + playTrack(item, index)} + onUnlike={() => toggleLike(item.id)} + /> + )} + /> + )} + + ); +} + +function LikedRow({ track, index, onPress, onUnlike }: { + track: Track; index: number; onPress: () => void; onUnlike: () => 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} + + + + + + + + + ); +} + +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: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.md }, + title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 }, + count: { color: Colors.text3, fontSize: Font.sm }, + empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 }, + emptyIcon: { fontSize: 52, color: Colors.text3, marginBottom: 16 }, + emptyTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: 8 }, + emptyText: { color: Colors.text2, fontSize: Font.sm, textAlign: 'center', lineHeight: 20 }, + 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 }, + info: { flex: 1 }, + trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, + artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, + likeBtn: { padding: 4 }, +}); diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 52dc1f9..9048446 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -1,125 +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.accent + '22', borderWidth: 1, borderColor: Colors.accent + '44' }, - planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' }, - planTextPro: { color: Colors.accent }, - 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' }, -}); +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.accent + '22', borderWidth: 1, borderColor: Colors.accent + '44' }, + planText: { color: Colors.text2, fontSize: Font.xs, fontWeight: '700' }, + planTextPro: { color: Colors.accent }, + 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 index fe3dea4..ab198ea 100644 --- a/app/(tabs)/search.tsx +++ b/app/(tabs)/search.tsx @@ -1,130 +1,130 @@ -import { useState, useCallback } from 'react'; -import { View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard } 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 Svg, { Path } from 'react-native-svg'; -import * as Haptics from 'expo-haptics'; -import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; -import { searchTracks, Track, coverUrl } from '../../services/api'; -import { usePlayer, useLikes } 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 { toggleLike, isLiked } = useLikes(); - - 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(); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - setQueue(results, index); - usePlayer.getState().setExpanded(true); - }; - - return ( - - Поиск - - - { setQuery(q); doSearch(q); }} - returnKeyType="search" - onSubmitEditing={() => doSearch(query)} - autoCorrect={false} - clearButtonMode="while-editing" - /> - - - {!searched && ( - - Введите название трека или исполнителя - - )} - - {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)} - style={[styles.row, usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id && styles.rowActive]} - > - {(() => { - const cover = coverUrl(item.cover_url); - const liked = isLiked(item.id); - const active = usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id; - return ( - <> - - - {item.title} - {item.artist} - - toggleLike(item.id)} hitSlop={12}> - - - - - {fmt(item.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: 12 }, - emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 }, - emptyText: { color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, - row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm }, - rowActive: { backgroundColor: Colors.surface }, - 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 }, -}); +import { useState, useCallback } from 'react'; +import { View, Text, TextInput, FlatList, StyleSheet, Pressable, Keyboard } 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 Svg, { Path } from 'react-native-svg'; +import * as Haptics from 'expo-haptics'; +import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; +import { searchTracks, Track, coverUrl } from '../../services/api'; +import { usePlayer, useLikes } 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 { toggleLike, isLiked } = useLikes(); + + 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(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setQueue(results, index); + usePlayer.getState().setExpanded(true); + }; + + return ( + + Поиск + + + { setQuery(q); doSearch(q); }} + returnKeyType="search" + onSubmitEditing={() => doSearch(query)} + autoCorrect={false} + clearButtonMode="while-editing" + /> + + + {!searched && ( + + Введите название трека или исполнителя + + )} + + {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)} + style={[styles.row, usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id && styles.rowActive]} + > + {(() => { + const cover = coverUrl(item.cover_url); + const liked = isLiked(item.id); + const active = usePlayer.getState().queue[usePlayer.getState().index]?.id === item.id; + return ( + <> + + + {item.title} + {item.artist} + + toggleLike(item.id)} hitSlop={12}> + + + + + {fmt(item.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: 12 }, + emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 120 }, + emptyText: { color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, + row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: Spacing.md, borderRadius: Radius.sm }, + rowActive: { backgroundColor: Colors.surface }, + 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 index e44583d..25c96cd 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,40 +1,40 @@ -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(); - } - // eslint-disable-next-line no-empty - } catch { - // silent fail - } - })(); - }, []); -} +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(); + } + // eslint-disable-next-line no-empty + } catch { + // silent fail + } + })(); + }, []); +} diff --git a/app/auth.tsx b/app/auth.tsx index 4b4a945..ece98fd 100644 --- a/app/auth.tsx +++ b/app/auth.tsx @@ -1,165 +1,165 @@ -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'; -import { saveUser } from '../hooks/useAuth'; - -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); - await saveUser(data.user); - 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.12 }, - card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 }, - logoWrap: { alignItems: 'center', marginBottom: 40 }, - logoIcon: { width: 84, height: 84, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.md, shadowColor: Colors.accent, shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 }, - logoTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: 4 }, - logoSub: { color: Colors.text3, fontSize: Font.sm, fontWeight: '500' }, - btnWrap: { width: '100%', borderRadius: Radius.md, overflow: 'hidden', shadowColor: Colors.accent, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 }, - btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 }, - btnText: { color: '#fff', fontSize: Font.md, fontWeight: '700' }, - error: { color: Colors.red, fontSize: Font.sm, textAlign: 'center', marginTop: Spacing.sm }, - footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 }, -}); +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'; +import { saveUser } from '../hooks/useAuth'; + +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); + await saveUser(data.user); + 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.12 }, + card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 }, + logoWrap: { alignItems: 'center', marginBottom: 40 }, + logoIcon: { width: 84, height: 84, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.md, shadowColor: Colors.accent, shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 }, + logoTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: 4 }, + logoSub: { color: Colors.text3, fontSize: Font.sm, fontWeight: '500' }, + btnWrap: { width: '100%', borderRadius: Radius.md, overflow: 'hidden', shadowColor: Colors.accent, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 }, + btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 }, + btnText: { color: '#fff', fontSize: Font.md, fontWeight: '700' }, + error: { color: Colors.red, fontSize: Font.sm, textAlign: 'center', marginTop: Spacing.sm }, + footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 }, +}); diff --git a/babel.config.js b/babel.config.js index d872de3..8bac423 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,7 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - plugins: ['react-native-reanimated/plugin'], - }; -}; +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['react-native-reanimated/plugin'], + }; +}; diff --git a/build-ios.yml b/build-ios.yml index 7f38455..1fdfdd3 100644 --- a/build-ios.yml +++ b/build-ios.yml @@ -1,56 +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 +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/build.yml b/build.yml new file mode 100644 index 0000000..9a4f321 --- /dev/null +++ b/build.yml @@ -0,0 +1,85 @@ +name: Build & Deploy iOS + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +env: + NODE_VERSION: '20' + EXPO_VERSION: 'latest' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Setup Expo + run: npm install -g expo-cli eas-cli + + - name: Lint & Type Check + run: | + npm run type-check 2>/dev/null || true + npm run lint 2>/dev/null || true + + - name: Build Preview (EAS) + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + env: + EAS_TOKEN: ${{ secrets.EAS_TOKEN }} + run: | + eas build --platform ios --profile preview --non-interactive --wait + + - name: Build Production (EAS) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + EAS_TOKEN: ${{ secrets.EAS_TOKEN }} + run: | + eas build --platform ios --profile production --non-interactive --wait + + - name: Release Notes + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + echo "### Build Complete ✅" >> $GITHUB_STEP_SUMMARY + echo "App version: $(cat package.json | grep version | head -1 | awk -F= '{ print $2 }' | sed 's/[version",]//g')" >> $GITHUB_STEP_SUMMARY + echo "Built for: iOS" >> $GITHUB_STEP_SUMMARY + echo "Download IPA from EAS Dashboard" >> $GITHUB_STEP_SUMMARY + + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check TypeScript + run: npx tsc --noEmit || true + + - name: Validate structure + run: | + test -f app.json && echo "✓ app.json exists" + test -f tsconfig.json && echo "✓ tsconfig.json exists" + test -d app && echo "✓ app directory exists" + test -d services && echo "✓ services directory exists" + test -d components && echo "✓ components directory exists" diff --git a/components/CoverImage.tsx b/components/CoverImage.tsx index e01465f..5f5171a 100644 --- a/components/CoverImage.tsx +++ b/components/CoverImage.tsx @@ -1,73 +1,73 @@ -import { useState, useEffect } from 'react'; -import { Text, ViewStyle } from 'react-native'; -import { Image } from 'expo-image'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Radius } from '../constants/theme'; - -// 12 distinct dark gradients — one is assigned per title hash -const GRADIENTS: [string, string][] = [ - ['#12234a', '#1e4080'], - ['#1a0e3a', '#3d1a7a'], - ['#0e2a3a', '#1a5a7a'], - ['#2a0e1e', '#6a1a3e'], - ['#0e2a1e', '#1a6a3e'], - ['#2a1e0e', '#7a4a1e'], - ['#1e0e2a', '#4a1e6a'], - ['#0e1e2a', '#1e4a6a'], - ['#2a0e2a', '#6a1a6a'], - ['#1e2a0e', '#4a6a1e'], - ['#2a1e1e', '#6a3a3a'], - ['#0e2a2a', '#1a6a6a'], -]; - -function textHash(s: string): number { - let h = 0; - for (const c of s) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0; - return Math.abs(h); -} - -interface Props { - uri: string | null; - title: string; - size: number; - radius?: number; - style?: ViewStyle; - blurRadius?: number; -} - -export default function CoverImage({ uri, title, size, radius = Radius.sm, style, blurRadius }: Props) { - const [failed, setFailed] = useState(false); - - // Reset error state when URI changes (new track loaded) - useEffect(() => { setFailed(false); }, [uri]); - - if (uri && !failed) { - return ( - setFailed(true)} - /> - ); - } - - const [c1, c2] = GRADIENTS[textHash(title || '') % GRADIENTS.length]; - const letter = (title || '?').trim().toUpperCase()[0]; - const fontSize = size * 0.36; - - return ( - - - {letter} - - - ); -} +import { useState, useEffect } from 'react'; +import { Text, ViewStyle } from 'react-native'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Radius } from '../constants/theme'; + +// 12 distinct dark gradients — one is assigned per title hash +const GRADIENTS: [string, string][] = [ + ['#12234a', '#1e4080'], + ['#1a0e3a', '#3d1a7a'], + ['#0e2a3a', '#1a5a7a'], + ['#2a0e1e', '#6a1a3e'], + ['#0e2a1e', '#1a6a3e'], + ['#2a1e0e', '#7a4a1e'], + ['#1e0e2a', '#4a1e6a'], + ['#0e1e2a', '#1e4a6a'], + ['#2a0e2a', '#6a1a6a'], + ['#1e2a0e', '#4a6a1e'], + ['#2a1e1e', '#6a3a3a'], + ['#0e2a2a', '#1a6a6a'], +]; + +function textHash(s: string): number { + let h = 0; + for (const c of s) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0; + return Math.abs(h); +} + +interface Props { + uri: string | null; + title: string; + size: number; + radius?: number; + style?: ViewStyle; + blurRadius?: number; +} + +export default function CoverImage({ uri, title, size, radius = Radius.sm, style, blurRadius }: Props) { + const [failed, setFailed] = useState(false); + + // Reset error state when URI changes (new track loaded) + useEffect(() => { setFailed(false); }, [uri]); + + if (uri && !failed) { + return ( + setFailed(true)} + /> + ); + } + + const [c1, c2] = GRADIENTS[textHash(title || '') % GRADIENTS.length]; + const letter = (title || '?').trim().toUpperCase()[0]; + const fontSize = size * 0.36; + + return ( + + + {letter} + + + ); +} diff --git a/components/DownloadButton.tsx b/components/DownloadButton.tsx new file mode 100644 index 0000000..114f343 --- /dev/null +++ b/components/DownloadButton.tsx @@ -0,0 +1,101 @@ +import { View, StyleSheet, Pressable, Text, ActivityIndicator } from 'react-native'; +import Animated, { FadeIn, useAnimatedStyle, withSpring } from 'react-native-reanimated'; +import { useMemo } from 'react'; +import Svg, { Path } from 'react-native-svg'; +import * as Haptics from 'expo-haptics'; +import { Colors, Font } from '../constants/theme'; + +export interface DownloadButtonProps { + trackId: number; + isDownloaded?: boolean; + downloadProgress?: number; // 0-1 + onDownload?: () => void; + onDelete?: () => void; + size?: 'sm' | 'md' | 'lg'; +} + +export default function DownloadButton({ + trackId, + isDownloaded = false, + downloadProgress = 0, + onDownload, + onDelete, + size = 'md', +}: DownloadButtonProps) { + const sizing = useMemo(() => { + const map = { sm: 20, md: 24, lg: 32 }; + return map[size]; + }, [size]); + + const handlePress = async () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + if (isDownloaded) { + onDelete?.(); + } else { + onDownload?.(); + } + }; + + const isLoading = downloadProgress > 0 && downloadProgress < 1; + + if (isLoading) { + return ( + + + + {downloadProgress > 0.2 && ( + + {Math.round(downloadProgress * 100)}% + + )} + + + ); + } + + return ( + [ + styles.button, + { + width: sizing, + height: sizing, + opacity: pressed ? 0.6 : 1, + }, + ]} + > + {isDownloaded ? ( + + + + ) : ( + + + + )} + + ); +} + +const styles = StyleSheet.create({ + button: { + justifyContent: 'center', + alignItems: 'center', + }, + container: { + justifyContent: 'center', + alignItems: 'center', + }, + progress: { + color: Colors.accent, + fontFamily: Font.family.medium, + marginTop: 2, + }, +}); diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index 95a5813..47aa739 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -1,129 +1,129 @@ -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 * as Haptics from 'expo-haptics'; -import Svg, { Path } from 'react-native-svg'; -import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme'; -import { usePlayer, useLikes } from '../store'; -import { togglePlay, seekTo } from '../hooks/useAudio'; -import { coverUrl } from '../services/api'; - -export default function MiniPlayer() { - const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer(); - const { toggleLike, isLiked } = useLikes(); - const track = queue[index]; - - 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 onNext = useCallback(() => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - const ni = usePlayer.getState().nextTrack(); - usePlayer.getState().setIndex(ni); - }, []); - - const onPrev = useCallback(() => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - const pi = usePlayer.getState().prevTrack(); - usePlayer.getState().setIndex(pi); - }, []); - - if (!track || expanded) return null; - - const cover = coverUrl(track.cover_url); - const liked = isLiked(track.id); - const pct = duration > 0 ? (progress / duration) * 100 : 0; - - return ( - - - - - - {/* Progress line */} - - - - - - - - {track.title} - {track.artist} - - - { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}> - - - - - { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}> - - - { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}> - {playing - ? - : } - - { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}> - - - - - - - ); -} - -const s = StyleSheet.create({ - wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderTopLeftRadius: 14, borderTopRightRadius: 14 }, - bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' }, - progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 }, - progressFill:{ height: 2, backgroundColor: Colors.accent }, - row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 }, - cover: { width: 44, height: 44, borderRadius: 9, backgroundColor: Colors.elevated, flexShrink: 0 }, - info: { flex: 1, minWidth: 0 }, - title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, - artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 }, - controls: { flexDirection: 'row', alignItems: 'center' }, - ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, -}); +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 * as Haptics from 'expo-haptics'; +import Svg, { Path } from 'react-native-svg'; +import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme'; +import { usePlayer, useLikes } from '../store'; +import { togglePlay, seekTo } from '../hooks/useAudio'; +import { coverUrl } from '../services/api'; + +export default function MiniPlayer() { + const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer(); + const { toggleLike, isLiked } = useLikes(); + const track = queue[index]; + + 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 onNext = useCallback(() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const ni = usePlayer.getState().nextTrack(); + usePlayer.getState().setIndex(ni); + }, []); + + const onPrev = useCallback(() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const pi = usePlayer.getState().prevTrack(); + usePlayer.getState().setIndex(pi); + }, []); + + if (!track || expanded) return null; + + const cover = coverUrl(track.cover_url); + const liked = isLiked(track.id); + const pct = duration > 0 ? (progress / duration) * 100 : 0; + + return ( + + + + + + {/* Progress line */} + + + + + + + + {track.title} + {track.artist} + + + { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}> + + + + + { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}> + + + { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}> + {playing + ? + : } + + { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}> + + + + + + + ); +} + +const s = StyleSheet.create({ + wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderTopLeftRadius: 14, borderTopRightRadius: 14 }, + bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' }, + progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 }, + progressFill:{ height: 2, backgroundColor: Colors.accent }, + row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 }, + cover: { width: 44, height: 44, borderRadius: 9, backgroundColor: Colors.elevated, flexShrink: 0 }, + info: { flex: 1, minWidth: 0 }, + title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, + artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 }, + controls: { flexDirection: 'row', alignItems: 'center' }, + ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/components/Player.tsx b/components/Player.tsx index 36cc170..111bda4 100644 --- a/components/Player.tsx +++ b/components/Player.tsx @@ -1,316 +1,480 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView } from 'react-native'; -import Animated, { - useSharedValue, useAnimatedStyle, withSpring, - useAnimatedGestureHandler, runOnJS, FadeIn, interpolate, -} 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 { BlurView } from 'expo-blur'; -import * as Haptics from 'expo-haptics'; -import Svg, { Path, Polyline, Line, Rect } from 'react-native-svg'; -import { Colors, Font, Radius } from '../constants/theme'; -import { usePlayer, useLikes } from '../store'; -import { togglePlay, seekTo } from '../hooks/useAudio'; -import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api'; - -const { width } = Dimensions.get('window'); -const COVER = width - 48; - -export default function Player() { - const { - queue, index, playing, progress, duration, - repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle, - } = usePlayer(); - const { toggleLike, isLiked } = useLikes(); - const insets = useSafeAreaInsets(); - const track = queue[index]; - - const [lyricsOpen, setLyricsOpen] = useState(false); - const [lrcLines, setLrcLines] = useState([]); - const [plainLyrics, setPlain] = useState(null); - const [lyricsLoading, setLyricsLoading] = useState(false); - const lyricsScrollRef = useRef(null); - const lineHeights = useRef([]); - - const translateY = useSharedValue(0); - const coverScale = useSharedValue(1); - - useEffect(() => { - coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 }); - }, [playing]); - - useEffect(() => { - setLrcLines([]); setPlain(null); setLyricsOpen(false); - }, [track?.id]); - - const loadLyrics = useCallback(async () => { - if (!track) return; - if (lrcLines.length > 0 || plainLyrics) { setLyricsOpen(true); return; } - setLyricsLoading(true); - const data = await fetchLyrics(track.id); - setLyricsLoading(false); - if (data?.synced) setLrcLines(parseLrc(data.synced)); - else if (data?.plain) setPlain(data.plain); - setLyricsOpen(true); - }, [track, lrcLines.length, plainLyrics]); - - const currentLineIdx = lrcLines.length > 0 - ? lrcLines.reduce((acc, line, i) => line.time <= progress ? i : acc, 0) - : -1; - - useEffect(() => { - if (!lyricsOpen || currentLineIdx < 0) return; - const y = lineHeights.current.slice(0, currentLineIdx).reduce((sum, h) => sum + h, 0); - lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 120), animated: true }); - }, [currentLineIdx, lyricsOpen]); - - 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 > 100 || e.velocityY > 700) runOnJS(close)(); - else translateY.value = withSpring(0, { damping: 20 }); - }, - }); - - const containerStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }] })); - const overlayOpacity = useAnimatedStyle(() => ({ opacity: interpolate(translateY.value, [0, 300], [1, 0]) })); - const coverStyle = useAnimatedStyle(() => ({ transform: [{ scale: coverScale.value }] })); - - 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) { seekTo(0); return; } - const pi = usePlayer.getState().prevTrack(); - usePlayer.getState().setIndex(pi); - }, [progress]); - - if (!track) return null; - - const cover = coverUrl(track.cover_url); - const pct = duration > 0 ? progress / duration : 0; - const liked = isLiked(track.id); - - return ( - - {/* Blurred background */} - - - - - - - - {/* Top bar */} - - Сейчас играет - - - - - - - - {/* Cover */} - - - - - - - {/* Meta */} - - - {track.title} - {track.artist} - - { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={12}> - - - - - - - {/* Seek */} - - seekTo(v * duration)} /> - - {fmt(progress)} - {fmt(duration)} - - - - {/* Controls */} - - { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}> - - - - - - - - - { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}> - {playing - ? - : } - - - - - - - - { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}> - - - - - {/* Lyrics button */} - - - - - - - {lyricsLoading ? 'Загрузка…' : 'Текст'} - - - - - - - - {/* Lyrics sheet */} - {lyricsOpen && ( - - setLyricsOpen(false)} /> - - setLyricsOpen(false)} style={s.lyrHandleBar}> - - - - {cover - ? - : } - - {track.title} - {track.artist} - - - - {lrcLines.length > 0 - ? lrcLines.map((line, i) => ( - { lineHeights.current[i] = e.nativeEvent.layout.height; }}> - {line.text} - - )) - : plainLyrics - ? {plainLyrics} - : Текст не найден} - - - - )} - - ); -} - -function CtlBtn({ onPress, active = false, children }: { onPress: () => void; active?: boolean; children: React.ReactNode }) { - const sc = useSharedValue(1); - const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] })); - return ( - - { sc.value = withSpring(0.82, { damping: 12 }); }} onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} hitSlop={12} style={[s.ctlBtn, active && s.ctlBtnActive]}> - {children} - - - ); -} - -function ShuffleIcon({ active }: { active: boolean }) { - const c = active ? Colors.accent : 'rgba(255,255,255,0.5)'; - return ( - - - - - - - ); -} - -function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) { - const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.5)'; - return ( - - - - - - - - {repeat === 2 && 1} - - ); -} - -function fmt(sec: number) { - const m = Math.floor(sec / 60), s = Math.floor(sec % 60); - return `${m}:${s < 10 ? '0' : ''}${s}`; -} - -const s = StyleSheet.create({ - root: { zIndex: 999 }, - bgDim: { backgroundColor: 'rgba(0,0,0,0.65)' }, - sheet: { flex: 1, paddingHorizontal: 24 }, - topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 8 }, - topLabel: { color: 'rgba(255,255,255,0.38)', fontSize: 11, fontWeight: '600', letterSpacing: 1.2, textTransform: 'uppercase' }, - closeBtn: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.1)', alignItems: 'center', justifyContent: 'center' }, - coverWrap: { alignItems: 'center', marginVertical: 20 }, - coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.7, shadowRadius: 32 }, - cover: { width: COVER, height: COVER, borderRadius: Radius.xl, backgroundColor: Colors.elevated }, - metaRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 12 }, - metaText: { flex: 1 }, - trackTitle: { color: Colors.text, fontSize: 22, fontWeight: '700', letterSpacing: -0.3 }, - artist: { color: 'rgba(255,255,255,0.55)', fontSize: 17, marginTop: 4 }, - seekWrap: { marginBottom: 8 }, - timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 }, - timeText: { color: 'rgba(255,255,255,0.35)', fontSize: Font.xs }, - controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 20 }, - ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full }, - ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' }, - playBtn: { width: 60, height: 60, borderRadius: 30, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center' }, - bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }, - lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 14, paddingVertical: 8, borderRadius: Radius.full }, - lyrBtnText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '600' }, - repBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center' }, - repBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' }, - lyrSheet: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' }, - lyrInner: { height: '70%', borderTopLeftRadius: 20, borderTopRightRadius: 20, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.1)' }, - lyrHandleBar: { alignItems: 'center', paddingTop: 12, paddingBottom: 4 }, - lyrHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.25)' }, - lyrHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 1, borderColor: 'rgba(255,255,255,0.06)' }, - lyrThumb: { width: 40, height: 40, borderRadius: 8, flexShrink: 0 }, - lyrTrackTitle: { fontSize: 13, fontWeight: '600', color: '#fff' }, - lyrArtist: { fontSize: 11, color: 'rgba(255,255,255,0.55)' }, - lyrScroll: { flex: 1 }, - lyrLine: { fontSize: 20, lineHeight: 32, color: 'rgba(255,255,255,0.28)', fontWeight: '600', marginBottom: 8, textAlign: 'center' }, - lyrLineActive: { fontSize: 28, lineHeight: 42, color: '#fff', marginBottom: 10 }, - plainLyrics: { fontSize: 15, lineHeight: 26, color: 'rgba(255,255,255,0.75)', textAlign: 'center' }, - lyrEmpty: { color: 'rgba(255,255,255,0.35)', fontSize: 14, textAlign: 'center', marginTop: 40 }, -}); +import { useCallback, useEffect, useRef, useState } from 'react'; +import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView, ViewStyle } from 'react-native'; +import Animated, { + useSharedValue, useAnimatedStyle, withSpring, withDecay, + useAnimatedGestureHandler, runOnJS, FadeIn, interpolate, Easing, withTiming, +} 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 { BlurView } from 'expo-blur'; +import * as Haptics from 'expo-haptics'; +import Svg, { Path, Polyline, Line, Rect, Circle } from 'react-native-svg'; +import { Colors, Font, Radius } from '../constants/theme'; +import { usePlayer, useLikes } from '../store'; +import { togglePlay, seekTo } from '../hooks/useAudio'; +import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api'; + +const { width, height } = Dimensions.get('window'); +const COVER = width - 48; +const MIN_SWIPE_DISTANCE = 50; +const MAX_SWIPE_DISTANCE = height * 0.4; + +export default function Player() { + const { + queue, index, playing, progress, duration, + repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle, + } = usePlayer(); + const { toggleLike, isLiked } = useLikes(); + const insets = useSafeAreaInsets(); + const track = queue[index]; + + const [lyricsOpen, setLyricsOpen] = useState(false); + const [lrcLines, setLrcLines] = useState([]); + const [plainLyrics, setPlain] = useState(null); + const [lyricsLoading, setLyricsLoading] = useState(false); + const lyricsScrollRef = useRef(null); + const lineHeights = useRef([]); + const gestureRef = useRef(null); + + const translateY = useSharedValue(0); + const coverScale = useSharedValue(1); + const coverRotate = useSharedValue(0); + + useEffect(() => { + coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 14, mass: 1, stiffness: 100 }); + }, [playing]); + + useEffect(() => { + setLrcLines([]); setPlain(null); setLyricsOpen(false); + translateY.value = 0; + }, [track?.id]); + + const loadLyrics = useCallback(async () => { + if (!track) return; + if (lrcLines.length > 0 || plainLyrics) { setLyricsOpen(true); return; } + setLyricsLoading(true); + const data = await fetchLyrics(track.id); + setLyricsLoading(false); + if (data?.synced) setLrcLines(parseLrc(data.synced)); + else if (data?.plain) setPlain(data.plain); + setLyricsOpen(true); + }, [track, lrcLines.length, plainLyrics]); + + const currentLineIdx = lrcLines.length > 0 + ? lrcLines.reduce((acc, line, i) => line.time <= progress ? i : acc, 0) + : -1; + + useEffect(() => { + if (!lyricsOpen || currentLineIdx < 0) return; + const y = lineHeights.current.slice(0, currentLineIdx).reduce((sum, h) => sum + h, 0); + lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 100), animated: true }); + }, [currentLineIdx, lyricsOpen]); + + const gestureHandler = useAnimatedGestureHandler({ + onStart: (_, ctx) => { + ctx.startY = translateY.value; + ctx.startX = 0; + }, + onActive: (e, ctx) => { + try { + // Only allow vertical swipe, prevent diagonal + if (Math.abs(e.translationX) > Math.abs(e.translationY) * 0.5) return; + + if (e.translationY > 0) { + const clamped = Math.min(e.translationY, MAX_SWIPE_DISTANCE); + translateY.value = ctx.startY + clamped; + coverRotate.value = withTiming(clamped / 100, { duration: 50, easing: Easing.linear }); + } + } catch (err) { + console.warn('Gesture error:', err); + } + }, + onEnd: (e) => { + try { + const shouldClose = e.translationY > MIN_SWIPE_DISTANCE || e.velocityY > 500; + + if (shouldClose) { + translateY.value = withTiming(height, { + duration: 300, + easing: Easing.out(Easing.ease) + }, () => { + runOnJS(close)(); + }); + coverRotate.value = withTiming(0, { duration: 300 }); + } else { + translateY.value = withSpring(0, { + damping: 15, + mass: 1, + stiffness: 120 + }); + coverRotate.value = withTiming(0, { duration: 200 }); + } + } catch (err) { + console.warn('Gesture end error:', err); + translateY.value = withSpring(0, { damping: 15, mass: 1 }); + } + }, + }); + + const containerStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }] + })); + + const overlayOpacity = useAnimatedStyle(() => ({ + opacity: interpolate(translateY.value, [0, MAX_SWIPE_DISTANCE], [1, 0.2]) + })); + + const coverStyle = useAnimatedStyle(() => ({ + transform: [ + { scale: coverScale.value }, + { rotateZ: `${coverRotate.value}deg` } + ] + })); + + 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) { seekTo(0); return; } + const pi = usePlayer.getState().prevTrack(); + usePlayer.getState().setIndex(pi); + }, [progress]); + + if (!track) return null; + + const cover = coverUrl(track.cover_url); + const pct = duration > 0 ? progress / duration : 0; + const liked = isLiked(track.id); + + return ( + + {/* Blurred background with better gradient */} + + + + + + + + {/* Top bar with better design */} + + Сейчас играет + + + + + + + + {/* Cover with better shadow */} + + + + + + + {/* Meta with better spacing */} + + + {track.title} + {track.artist} + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={16}> + + + + + + + {/* Seek with gradient track */} + + + seekTo(v * duration)} + /> + + + {fmt(progress)} + {fmt(duration - progress)} + + + + {/* Controls with better styling */} + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}> + + + + + + + + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}> + {playing + ? + : } + + + + + + + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}> + + + + + {/* Lyrics button */} + + + + + + + {lyricsLoading ? 'Загрузка…' : 'Текст'} + + + + + + + + {/* Lyrics sheet with better design */} + {lyricsOpen && ( + + setLyricsOpen(false)} /> + + setLyricsOpen(false)} style={s.lyrHandleBar}> + + + + {cover + ? + : } + + {track.title} + {track.artist} + + + + {lrcLines.length > 0 + ? lrcLines.map((line, i) => ( + { lineHeights.current[i] = e.nativeEvent.layout.height; }} + > + {line.text} + + )) + : plainLyrics + ? {plainLyrics} + : Текст не найден} + + + + )} + + ); +} + {fmt(progress)} + {fmt(duration)} + + + + {/* Controls */} + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}> + + + + + + + + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}> + {playing + ? + : } + + + + + + + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}> + + + + + {/* Lyrics button */} + + + + + + + {lyricsLoading ? 'Загрузка…' : 'Текст'} + + + + + + + + {/* Lyrics sheet */} + {lyricsOpen && ( + + setLyricsOpen(false)} /> + + setLyricsOpen(false)} style={s.lyrHandleBar}> + + + + {cover + ? + : } + + {track.title} + {track.artist} + + + + {lrcLines.length > 0 + ? lrcLines.map((line, i) => ( + { lineHeights.current[i] = e.nativeEvent.layout.height; }}> + {line.text} + + )) + : plainLyrics + ? {plainLyrics} + : Текст не найден} + + + + )} + + ); +} + +function CtlBtn({ onPress, active = false, children }: { onPress: () => void; active?: boolean; children: React.ReactNode }) { + const sc = useSharedValue(1); + const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] })); + return ( + + { sc.value = withSpring(0.75, { damping: 10, mass: 0.8 }); }} + onPressOut={() => { sc.value = withSpring(1, { damping: 10, mass: 0.8 }); }} + hitSlop={14} + style={[s.ctlBtn, active && s.ctlBtnActive]} + > + {children} + + + ); +} + +function ShuffleIcon({ active }: { active: boolean }) { + const c = active ? Colors.accent : 'rgba(255,255,255,0.6)'; + return ( + + + + + + + ); +} + +function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) { + const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.6)'; + return ( + + + + + + + + {repeat === 2 && 1} + + ); +} + +function fmt(sec: number) { + const m = Math.floor(sec / 60), s = Math.floor(sec % 60); + return `${m}:${s < 10 ? '0' : ''}${s}`; +} + +const s = StyleSheet.create({ + root: { zIndex: 999 }, + bgDim: { backgroundColor: 'rgba(0,0,0,0.7)' }, + sheet: { flex: 1, paddingHorizontal: 24 }, + topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 10, marginBottom: 8 }, + topLabel: { color: 'rgba(255,255,255,0.4)', fontSize: 11, fontWeight: '700', letterSpacing: 1.3, textTransform: 'uppercase' }, + closeBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.12)', alignItems: 'center', justifyContent: 'center' }, + coverWrap: { alignItems: 'center', marginVertical: 24, marginBottom: 32 }, + coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 28 }, shadowOpacity: 0.8, shadowRadius: 40 }, + cover: { width: COVER, height: COVER, borderRadius: Radius.xl, backgroundColor: Colors.elevated, overflow: 'hidden' }, + metaRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 16, marginBottom: 16 }, + metaText: { flex: 1 }, + trackTitle: { color: Colors.text, fontSize: 24, fontWeight: '800', letterSpacing: -0.5, lineHeight: 30 }, + artist: { color: 'rgba(255,255,255,0.6)', fontSize: 16, marginTop: 6, fontWeight: '500' }, + seekWrap: { marginBottom: 12 }, + sliderContainer: { height: 40, justifyContent: 'center' }, + timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 2 }, + timeText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '500' }, + controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 6, marginBottom: 24, marginTop: 16 }, + ctlBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', borderRadius: 24 }, + ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.08)' }, + playBtn: { width: 64, height: 64, borderRadius: 32, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center', shadowColor: Colors.accent, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 12 }, + bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }, + lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.1)', paddingHorizontal: 16, paddingVertical: 10, borderRadius: Radius.full, borderWidth: 1, borderColor: 'rgba(255,255,255,0.05)' }, + lyrBtnActive: { backgroundColor: `${Colors.accent}15`, borderColor: `${Colors.accent}40` }, + lyrBtnText: { color: 'rgba(255,255,255,0.5)', fontSize: Font.sm, fontWeight: '600' }, + repBadge: { position: 'absolute', top: -4, right: -4, width: 12, height: 12, borderRadius: 6, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center', shadowColor: Colors.accent, shadowOpacity: 0.5, shadowRadius: 4 }, + repBadgeText: { color: '#000', fontSize: 6, fontWeight: '900' }, + + // Lyrics sheet + lyrSheet: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' }, + lyrInner: { height: '75%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.08)' }, + lyrHandleBar: { alignItems: 'center', paddingTop: 14, paddingBottom: 8 }, + lyrHandle: { width: 40, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.28)' }, + lyrHeader: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingHorizontal: 20, paddingVertical: 14, borderBottomWidth: 1, borderColor: 'rgba(255,255,255,0.06)' }, + lyrThumb: { width: 44, height: 44, borderRadius: 10, flexShrink: 0, shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 6 }, + lyrTrackTitle: { fontSize: 14, fontWeight: '700', color: '#fff' }, + lyrArtist: { fontSize: 12, color: 'rgba(255,255,255,0.6)', marginTop: 2 }, + lyrScroll: { flex: 1 }, + lyrLine: { fontSize: 18, lineHeight: 30, color: 'rgba(255,255,255,0.25)', fontWeight: '600', marginBottom: 12, textAlign: 'center' }, + lyrLineActive: { fontSize: 32, lineHeight: 44, color: Colors.accent, marginBottom: 16, fontWeight: '800' }, + lyrLinePassed: { color: 'rgba(255,255,255,0.35)' }, + plainLyrics: { fontSize: 16, lineHeight: 28, color: 'rgba(255,255,255,0.75)', textAlign: 'center', fontWeight: '500' }, + lyrEmpty: { color: 'rgba(255,255,255,0.38)', fontSize: 15, textAlign: 'center', marginTop: 60, fontWeight: '500' }, +}); diff --git a/constants/theme.ts b/constants/theme.ts index 7b3133c..86db041 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -1,48 +1,48 @@ -export const Colors = { - bg: '#0a0a0a', - surface: '#111111', - elevated: '#1a1a1a', - subtle: '#2a2a2a', - border: 'rgba(255,255,255,0.07)', - border2: 'rgba(255,255,255,0.14)', - accent: '#4F86F5', - accentDim:'rgba(79,134,245,0.18)', - pink: '#e84393', - text: '#f0f0f0', - text2: '#888888', - text3: '#444444', - red: '#e84040', - white: '#ffffff', - // legacy alias - green: '#4F86F5', -}; - -export const Spacing = { - xs: 4, - sm: 8, - md: 16, - lg: 24, - xl: 32, - xxl: 48, -}; - -export const Radius = { - sm: 8, - md: 10, - lg: 14, - xl: 16, - full: 9999, -}; - -export const Font = { - xs: 11, - sm: 13, - md: 14, - lg: 17, - xl: 20, - xxl: 26, - xxxl: 32, -}; - -export const TAB_BAR_HEIGHT = 88; -export const MINI_PLAYER_HEIGHT = 64; +export const Colors = { + bg: '#0a0a0a', + surface: '#111111', + elevated: '#1a1a1a', + subtle: '#2a2a2a', + border: 'rgba(255,255,255,0.07)', + border2: 'rgba(255,255,255,0.14)', + accent: '#4F86F5', + accentDim:'rgba(79,134,245,0.18)', + pink: '#e84393', + text: '#f0f0f0', + text2: '#888888', + text3: '#444444', + red: '#e84040', + white: '#ffffff', + // legacy alias + green: '#4F86F5', +}; + +export const Spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 48, +}; + +export const Radius = { + sm: 8, + md: 10, + lg: 14, + xl: 16, + full: 9999, +}; + +export const Font = { + xs: 11, + sm: 13, + md: 14, + 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 index accd87f..ea78a70 100644 --- a/eas.json +++ b/eas.json @@ -1,26 +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": {} - } -} +{ + "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/PlaybackService.ts b/hooks/PlaybackService.ts index c78c73b..00262d2 100644 --- a/hooks/PlaybackService.ts +++ b/hooks/PlaybackService.ts @@ -1,55 +1,55 @@ -import TrackPlayer, { Event } from 'react-native-track-player'; -import { usePlayer } from '../store'; -import { loadAndPlay } from './useAudio'; - -export async function PlaybackService() { - TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play()); - TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause()); - - TrackPlayer.addEventListener(Event.RemoteNext, async () => { - const state = usePlayer.getState(); - const ni = state.nextTrack(); - state.setIndex(ni); - const track = state.queue[ni]; - if (track) await loadAndPlay(track).catch(console.error); - }); - - TrackPlayer.addEventListener(Event.RemotePrevious, async () => { - const state = usePlayer.getState(); - const progress = await TrackPlayer.getProgress(); - if (progress.position > 3) { - await TrackPlayer.seekTo(0); - return; - } - const pi = state.prevTrack(); - state.setIndex(pi); - const track = state.queue[pi]; - if (track) await loadAndPlay(track).catch(console.error); - }); - - TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => - TrackPlayer.seekTo(position), - ); - - TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => { - const state = usePlayer.getState(); - const prevIdx = state.index; - - if (state.repeat === 2) { - await TrackPlayer.seekTo(0); - await TrackPlayer.play(); - return; - } - - const ni = state.nextTrack(); - state.setIndex(ni); - - if (ni === prevIdx) { - state.setPlaying(false); - return; - } - - const track = state.queue[ni]; - if (track) await loadAndPlay(track).catch(console.error); - }); -} +import TrackPlayer, { Event } from 'react-native-track-player'; +import { usePlayer } from '../store'; +import { loadAndPlay } from './useAudio'; + +export async function PlaybackService() { + TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play()); + TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause()); + + TrackPlayer.addEventListener(Event.RemoteNext, async () => { + const state = usePlayer.getState(); + const ni = state.nextTrack(); + state.setIndex(ni); + const track = state.queue[ni]; + if (track) await loadAndPlay(track).catch(console.error); + }); + + TrackPlayer.addEventListener(Event.RemotePrevious, async () => { + const state = usePlayer.getState(); + const progress = await TrackPlayer.getProgress(); + if (progress.position > 3) { + await TrackPlayer.seekTo(0); + return; + } + const pi = state.prevTrack(); + state.setIndex(pi); + const track = state.queue[pi]; + if (track) await loadAndPlay(track).catch(console.error); + }); + + TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => + TrackPlayer.seekTo(position), + ); + + TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => { + const state = usePlayer.getState(); + const prevIdx = state.index; + + if (state.repeat === 2) { + await TrackPlayer.seekTo(0); + await TrackPlayer.play(); + return; + } + + const ni = state.nextTrack(); + state.setIndex(ni); + + if (ni === prevIdx) { + state.setPlaying(false); + return; + } + + const track = state.queue[ni]; + if (track) await loadAndPlay(track).catch(console.error); + }); +} diff --git a/hooks/useAudio.ts b/hooks/useAudio.ts index ebaa9c6..a1c08e4 100644 --- a/hooks/useAudio.ts +++ b/hooks/useAudio.ts @@ -1,106 +1,108 @@ -import { useEffect } from 'react'; -import TrackPlayer, { - State, - Capability, - AppKilledPlaybackBehavior, - usePlaybackState, - useProgress, -} from 'react-native-track-player'; -import { usePlayer } from '../store'; -import { coverUrl, Track } from '../services/api'; - -let _setupDone = false; -let _lastLoadedId: number | null = null; - -async function ensureSetup() { - if (_setupDone) return; - _setupDone = true; - try { - await TrackPlayer.setupPlayer({ minBuffer: 15, maxBuffer: 50, playBuffer: 2.5 }); - await TrackPlayer.updateOptions({ - android: { - appKilledPlaybackBehavior: - AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification, - }, - capabilities: [ - Capability.Play, - Capability.Pause, - Capability.SkipToNext, - Capability.SkipToPrevious, - Capability.SeekTo, - ], - compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext], - }); - } catch (e: any) { - // Player already set up on hot reload — update options only - if (e?.message?.toLowerCase().includes('already')) { - _setupDone = true; - return; - } - _setupDone = false; - throw e; - } -} - -// Load a track and start playing. Mutex prevents double-load on the same ID. -export async function loadAndPlay(track: Track) { - if (_lastLoadedId === track.id) return; - _lastLoadedId = track.id; - try { - await TrackPlayer.reset(); - await TrackPlayer.add({ - id: String(track.id), - url: track.cdn_url2 || track.cdn_url, - title: track.title, - artist: track.artist, - artwork: coverUrl(track.cover_url) ?? undefined, - duration: track.duration, - }); - await TrackPlayer.play(); - } catch (e) { - _lastLoadedId = null; - throw e; - } -} - -export async function togglePlay() { - const { state } = await TrackPlayer.getPlaybackState(); - if (state === State.Playing) await TrackPlayer.pause(); - else await TrackPlayer.play(); -} - -export async function seekTo(seconds: number) { - await TrackPlayer.seekTo(seconds); -} - -// Call ONCE from the root layout only -export function useAudio() { - const { queue, index, setPlaying, setProgress, setDuration } = usePlayer(); - const currentTrack = queue[index] ?? null; - const playbackState = usePlaybackState(); - const progress = useProgress(500); - - // One-time player setup - useEffect(() => { - ensureSetup().catch(console.error); - }, []); - - // Sync TrackPlayer progress → store - useEffect(() => { - setProgress(progress.position); - setDuration(progress.duration); - }, [progress.position, progress.duration]); - - // Sync TrackPlayer playing state → store - useEffect(() => { - setPlaying(playbackState.state === State.Playing); - }, [playbackState.state]); - - // Load new track when queue index changes - useEffect(() => { - if (!currentTrack) return; - loadAndPlay(currentTrack).catch(console.error); - }, [currentTrack?.id]); - - return { togglePlay, seek: seekTo }; -} +import { useEffect } from 'react'; +import TrackPlayer, { + State, + Capability, + AppKilledPlaybackBehavior, + usePlaybackState, + useProgress, +} from 'react-native-track-player'; +import { usePlayer } from '../store'; +import { coverUrl, Track, getTrackUrl } from '../services/api'; + +let _setupDone = false; +let _lastLoadedId: number | null = null; + +async function ensureSetup() { + if (_setupDone) return; + _setupDone = true; + try { + await TrackPlayer.setupPlayer({ minBuffer: 15, maxBuffer: 50, playBuffer: 2.5 }); + await TrackPlayer.updateOptions({ + android: { + appKilledPlaybackBehavior: + AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification, + }, + capabilities: [ + Capability.Play, + Capability.Pause, + Capability.SkipToNext, + Capability.SkipToPrevious, + Capability.SeekTo, + ], + compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext], + }); + } catch (e: any) { + // Player already set up on hot reload — update options only + if (e?.message?.toLowerCase().includes('already')) { + _setupDone = true; + return; + } + _setupDone = false; + throw e; + } +} + +// Load a track and start playing. Mutex prevents double-load on the same ID. +// Uses local file if downloaded, otherwise streams from CDN +export async function loadAndPlay(track: Track) { + if (_lastLoadedId === track.id) return; + _lastLoadedId = track.id; + try { + const url = await getTrackUrl(track); + await TrackPlayer.reset(); + await TrackPlayer.add({ + id: String(track.id), + url: url || (track.cdn_url2 || track.cdn_url), + title: track.title, + artist: track.artist, + artwork: coverUrl(track.cover_url) ?? undefined, + duration: track.duration, + }); + await TrackPlayer.play(); + } catch (e) { + _lastLoadedId = null; + throw e; + } +} + +export async function togglePlay() { + const { state } = await TrackPlayer.getPlaybackState(); + if (state === State.Playing) await TrackPlayer.pause(); + else await TrackPlayer.play(); +} + +export async function seekTo(seconds: number) { + await TrackPlayer.seekTo(seconds); +} + +// Call ONCE from the root layout only +export function useAudio() { + const { queue, index, setPlaying, setProgress, setDuration } = usePlayer(); + const currentTrack = queue[index] ?? null; + const playbackState = usePlaybackState(); + const progress = useProgress(500); + + // One-time player setup + useEffect(() => { + ensureSetup().catch(console.error); + }, []); + + // Sync TrackPlayer progress → store + useEffect(() => { + setProgress(progress.position); + setDuration(progress.duration); + }, [progress.position, progress.duration]); + + // Sync TrackPlayer playing state → store + useEffect(() => { + setPlaying(playbackState.state === State.Playing); + }, [playbackState.state]); + + // Load new track when queue index changes + useEffect(() => { + if (!currentTrack) return; + loadAndPlay(currentTrack).catch(console.error); + }, [currentTrack?.id]); + + return { togglePlay, seek: seekTo }; +} diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index eea0c17..a201b46 100644 --- a/hooks/useAuth.ts +++ b/hooks/useAuth.ts @@ -1,58 +1,58 @@ -import { useEffect } from 'react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { getAccessToken, getMe, clearTokens, User } from '../services/api'; -import { useAuth } from '../store'; - -const USER_KEY = 'ns-music-user'; - -export async function saveUser(user: User): Promise { - await AsyncStorage.setItem(USER_KEY, JSON.stringify(user)); -} - -export async function loadCachedUser(): Promise { - try { - const raw = await AsyncStorage.getItem(USER_KEY); - return raw ? JSON.parse(raw) : null; - } catch { return null; } -} - -export function useBootAuth() { - const { setUser, setReady } = useAuth(); - - useEffect(() => { - (async () => { - try { - const token = await getAccessToken(); - if (!token) { setReady(true); return; } - - // Use cached user for immediate display while validating - const cached = await loadCachedUser(); - if (cached) setUser(cached); - - // Validate token and get fresh data - const me = await getMe(); - if (me) { - // Preserve plan from initial login since no endpoint exposes it - const merged: User = { ...me, plan: cached?.plan || me.plan }; - setUser(merged); - saveUser(merged).catch(() => {}); - } else { - // Token invalid — clear everything - await clearTokens(); - await AsyncStorage.removeItem(USER_KEY); - setUser(null); - } - } catch { - // Network error — cached user already set above, just mark ready - } finally { - setReady(true); - } - })(); - }, []); -} - -export async function logout() { - await clearTokens(); - await AsyncStorage.removeItem(USER_KEY).catch(() => {}); - useAuth.getState().setUser(null); -} +import { useEffect } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { getAccessToken, getMe, clearTokens, User } from '../services/api'; +import { useAuth } from '../store'; + +const USER_KEY = 'ns-music-user'; + +export async function saveUser(user: User): Promise { + await AsyncStorage.setItem(USER_KEY, JSON.stringify(user)); +} + +export async function loadCachedUser(): Promise { + try { + const raw = await AsyncStorage.getItem(USER_KEY); + return raw ? JSON.parse(raw) : null; + } catch { return null; } +} + +export function useBootAuth() { + const { setUser, setReady } = useAuth(); + + useEffect(() => { + (async () => { + try { + const token = await getAccessToken(); + if (!token) { setReady(true); return; } + + // Use cached user for immediate display while validating + const cached = await loadCachedUser(); + if (cached) setUser(cached); + + // Validate token and get fresh data + const me = await getMe(); + if (me) { + // Preserve plan from initial login since no endpoint exposes it + const merged: User = { ...me, plan: cached?.plan || me.plan }; + setUser(merged); + saveUser(merged).catch(() => {}); + } else { + // Token invalid — clear everything + await clearTokens(); + await AsyncStorage.removeItem(USER_KEY); + setUser(null); + } + } catch { + // Network error — cached user already set above, just mark ready + } finally { + setReady(true); + } + })(); + }, []); +} + +export async function logout() { + await clearTokens(); + await AsyncStorage.removeItem(USER_KEY).catch(() => {}); + useAuth.getState().setUser(null); +} diff --git a/index.js b/index.js index ce52c01..c190618 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ -import 'expo-router/entry'; -import TrackPlayer from 'react-native-track-player'; -import { PlaybackService } from './hooks/PlaybackService'; - -TrackPlayer.registerPlaybackService(() => PlaybackService); +import 'expo-router/entry'; +import TrackPlayer from 'react-native-track-player'; +import { PlaybackService } from './hooks/PlaybackService'; + +TrackPlayer.registerPlaybackService(() => PlaybackService); diff --git a/main.yml b/main.yml index 7f38455..1fdfdd3 100644 --- a/main.yml +++ b/main.yml @@ -1,56 +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 +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 index 24f7644..9518b06 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,4 +1,4 @@ -const { getDefaultConfig } = require('expo/metro-config'); - -const config = getDefaultConfig(__dirname); -module.exports = config; +const { getDefaultConfig } = require('expo/metro-config'); + +const config = getDefaultConfig(__dirname); +module.exports = config; diff --git a/package.json b/package.json index 9978aae..20ec718 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,44 @@ -{ - "name": "burmalda-music", - "version": "1.0.0", - "main": "./index.js", - "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-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", - "react-native-track-player": "^4.1.1" - }, - "devDependencies": { - "@babel/core": "^7.24.0", - "@types/react": "~18.2.79", - "@types/react-native": "~0.73.0", - "typescript": "~5.3.3" - } -} +{ + "name": "burmalda-music", + "version": "1.0.0", + "main": "./index.js", + "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-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", + "expo-file-system": "~16.0.9", + "expo-network": "~6.0.2", + "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", + "react-native-track-player": "^4.1.1" + }, + "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 index 4845c75..49a6207 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,178 +1,195 @@ -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'); -} - -// ── Fetch helper ────────────────────────────────────────────────────────────── - -export 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; - cdn_url: string; - cdn_url2: string; - filename: string; - album?: string; - play_count?: number; - source?: string; - cdn_synced?: 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; -} - -export interface Lyrics { - synced: string | null; - plain: string | null; -} - -// ── API calls ───────────────────────────────────────────────────────────────── - -export async function getTracks(): Promise { - const r = await apiFetch('/api/music/tracks?limit=200'); - if (!r.ok) return []; - return r.json(); -} - -export async function getAlbums(): Promise { - const r = await apiFetch('/api/music/albums'); - if (!r.ok) return []; - const data: Array<{ album: string; artist: string; count: number }> = await r.json(); - return data.map((a, i) => ({ - id: i, - title: a.album, - artist: a.artist, - cover_url: null, - track_count: a.count, - })); -} - -export async function getMe(): Promise { - // Validate token - const r = await apiFetch('/api/auth/me'); - if (!r.ok) return null; - const auth = await r.json(); - - // Get music profile for username / avatar - const pr = await apiFetch('/api/music/profile/me'); - const profile = pr.ok ? await pr.json() : {}; - - return { - id: auth.id, - email: auth.email, - username: profile.username || null, - avatar_url: profile.avatar_url || null, - plan: 'free', // merged with cached plan in useBootAuth - }; -} - -export async function searchTracks(q: string): Promise { - const r = await apiFetch('/api/music/tracks?limit=200'); - if (!r.ok) return []; - const all: Track[] = await r.json(); - if (!q.trim()) return all; - const lq = q.toLowerCase(); - return all.filter(t => - t.title.toLowerCase().includes(lq) || - t.artist.toLowerCase().includes(lq) || - (t.album && t.album.toLowerCase().includes(lq)) - ); -} - -export async function fetchLyrics(id: number): Promise { - const r = await apiFetch(`/api/music/lyrics/${id}`); - if (!r.ok) return null; - return r.json(); -} - -// cover_url from the server is one of: -// - "https://cdn.nodeshift.space/music/xxx.jpg" (full URL) -// - "/api/music/cover/xxx.jpg" (relative path) -// - "xxx.jpg" (bare filename, legacy) -export function coverUrl(raw: string | null): string | null { - if (!raw) return null; - if (raw.startsWith('http')) return raw; - if (raw.startsWith('/')) return `${BASE}${raw}`; - return `${BASE}/api/music/cover/${raw}`; -} - -// ── LRC parser ──────────────────────────────────────────────────────────────── - -export interface LrcLine { - time: number; - text: string; -} - -export function parseLrc(lrc: string): LrcLine[] { - const lines: LrcLine[] = []; - for (const raw of lrc.split('\n')) { - const m = raw.match(/^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$/); - if (!m) continue; - const time = parseInt(m[1]) * 60 + parseFloat(`${m[2]}.${m[3]}`); - if (m[4].trim()) lines.push({ time, text: m[4].trim() }); - } - return lines.sort((a, b) => a.time - b.time); -} +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'); +} + +// ── Fetch helper ────────────────────────────────────────────────────────────── + +export 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; + cdn_url: string; + cdn_url2: string; + filename: string; + album?: string; + play_count?: number; + source?: string; + cdn_synced?: 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; +} + +export interface Lyrics { + synced: string | null; + plain: string | null; +} + +// ── API calls ───────────────────────────────────────────────────────────────── + +export async function getTracks(): Promise { + const r = await apiFetch('/api/music/tracks?limit=200'); + if (!r.ok) return []; + return r.json(); +} + +export async function getAlbums(): Promise { + const r = await apiFetch('/api/music/albums'); + if (!r.ok) return []; + const data: Array<{ album: string; artist: string; count: number }> = await r.json(); + return data.map((a, i) => ({ + id: i, + title: a.album, + artist: a.artist, + cover_url: null, + track_count: a.count, + })); +} + +export async function getMe(): Promise { + // Validate token + const r = await apiFetch('/api/auth/me'); + if (!r.ok) return null; + const auth = await r.json(); + + // Get music profile for username / avatar + const pr = await apiFetch('/api/music/profile/me'); + const profile = pr.ok ? await pr.json() : {}; + + return { + id: auth.id, + email: auth.email, + username: profile.username || null, + avatar_url: profile.avatar_url || null, + plan: 'free', // merged with cached plan in useBootAuth + }; +} + +export async function searchTracks(q: string): Promise { + const r = await apiFetch('/api/music/tracks?limit=200'); + if (!r.ok) return []; + const all: Track[] = await r.json(); + if (!q.trim()) return all; + const lq = q.toLowerCase(); + return all.filter(t => + t.title.toLowerCase().includes(lq) || + t.artist.toLowerCase().includes(lq) || + (t.album && t.album.toLowerCase().includes(lq)) + ); +} + +export async function fetchLyrics(id: number): Promise { + const r = await apiFetch(`/api/music/lyrics/${id}`); + if (!r.ok) return null; + return r.json(); +} + +// cover_url from the server is one of: +// - "https://cdn.nodeshift.space/music/xxx.jpg" (full URL) +// - "/api/music/cover/xxx.jpg" (relative path) +// - "xxx.jpg" (bare filename, legacy) +export function coverUrl(raw: string | null): string | null { + if (!raw) return null; + if (raw.startsWith('http')) return raw; + if (raw.startsWith('/')) return `${BASE}${raw}`; + return `${BASE}/api/music/cover/${raw}`; +} + +// ── Offline/Local playback ──────────────────────────────────────────────────── + +export async function getTrackUrl(track: Track): Promise { + try { + const { isTrackDownloaded, getTrackFilePath } = await import('./offline'); + const isDownloaded = await isTrackDownloaded(track.id); + if (isDownloaded) { + return getTrackFilePath(track.id); + } + } catch (err) { + console.warn('Failed to check downloaded track:', err); + } + + // Fallback to CDN URL + return track.cdn_url || track.cdn_url2 || ''; +} + +// ── LRC parser ──────────────────────────────────────────────────────────────── + +export interface LrcLine { + time: number; + text: string; +} + +export function parseLrc(lrc: string): LrcLine[] { + const lines: LrcLine[] = []; + for (const raw of lrc.split('\n')) { + const m = raw.match(/^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$/); + if (!m) continue; + const time = parseInt(m[1]) * 60 + parseFloat(`${m[2]}.${m[3]}`); + if (m[4].trim()) lines.push({ time, text: m[4].trim() }); + } + return lines.sort((a, b) => a.time - b.time); +} diff --git a/services/offline.ts b/services/offline.ts new file mode 100644 index 0000000..c34874e --- /dev/null +++ b/services/offline.ts @@ -0,0 +1,193 @@ +import * as FileSystem from 'expo-file-system'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Track, Album } from './api'; + +const OFFLINE_TRACKS_KEY = 'offline_tracks_cache'; +const OFFLINE_ALBUMS_KEY = 'offline_albums_cache'; +const DOWNLOADED_TRACKS_KEY = 'downloaded_tracks'; + +const DOCUMENTS_DIR = `${FileSystem.documentDirectory}music/`; + +// ── Initialization ───────────────────────────────────────────────────────────── + +export async function initOfflineStorage() { + try { + const dir = await FileSystem.getInfoAsync(DOCUMENTS_DIR); + if (!dir.exists) { + await FileSystem.makeDirectoryAsync(DOCUMENTS_DIR, { intermediates: true }); + } + } catch (err) { + console.warn('Failed to create offline storage dir:', err); + } +} + +// ── Cache management ─────────────────────────────────────────────────────────── + +export async function cacheTracksAndAlbums(tracks: Track[], albums: Album[]) { + try { + await Promise.all([ + AsyncStorage.setItem(OFFLINE_TRACKS_KEY, JSON.stringify(tracks)), + AsyncStorage.setItem(OFFLINE_ALBUMS_KEY, JSON.stringify(albums)), + ]); + } catch (err) { + console.warn('Failed to cache tracks and albums:', err); + } +} + +export async function getCachedTracks(): Promise { + try { + const cached = await AsyncStorage.getItem(OFFLINE_TRACKS_KEY); + return cached ? JSON.parse(cached) : null; + } catch (err) { + console.warn('Failed to get cached tracks:', err); + return null; + } +} + +export async function getCachedAlbums(): Promise { + try { + const cached = await AsyncStorage.getItem(OFFLINE_ALBUMS_KEY); + return cached ? JSON.parse(cached) : null; + } catch (err) { + console.warn('Failed to get cached albums:', err); + return null; + } +} + +// ── Downloaded tracks management ─────────────────────────────────────────────── + +export async function getDownloadedTracks(): Promise { + try { + const raw = await AsyncStorage.getItem(DOWNLOADED_TRACKS_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +export async function addDownloadedTrack(trackId: number) { + try { + const ids = await getDownloadedTracks(); + if (!ids.includes(trackId)) { + ids.push(trackId); + await AsyncStorage.setItem(DOWNLOADED_TRACKS_KEY, JSON.stringify(ids)); + } + } catch (err) { + console.warn('Failed to add downloaded track:', err); + } +} + +export async function removeDownloadedTrack(trackId: number) { + try { + let ids = await getDownloadedTracks(); + ids = ids.filter(id => id !== trackId); + await AsyncStorage.setItem(DOWNLOADED_TRACKS_KEY, JSON.stringify(ids)); + } catch (err) { + console.warn('Failed to remove downloaded track:', err); + } +} + +export function getTrackFilePath(trackId: number): string { + return `${DOCUMENTS_DIR}track_${trackId}.mp3`; +} + +export async function isTrackDownloaded(trackId: number): Promise { + try { + const info = await FileSystem.getInfoAsync(getTrackFilePath(trackId)); + return info.exists; + } catch { + return false; + } +} + +// ── Track download ─────────────────────────────────────────────────────────── + +export interface DownloadProgress { + trackId: number; + progress: number; + loaded: number; + total: number; +} + +export async function downloadTrack( + track: Track, + onProgress?: (progress: DownloadProgress) => void +): Promise { + try { + const filePath = getTrackFilePath(track.id); + const uri = track.cdn_url || track.cdn_url2; + + if (!uri) { + console.warn('No download URL for track:', track.id); + return false; + } + + // Check if already downloaded + if (await isTrackDownloaded(track.id)) { + await addDownloadedTrack(track.id); + return true; + } + + // Download with progress tracking + const downloadResumable = FileSystem.createDownloadResumable( + uri, + filePath, + {}, + (progress) => { + const { totalBytesWritten, totalBytesExpectedToDownload } = progress; + onProgress?.({ + trackId: track.id, + progress: totalBytesWritten / totalBytesExpectedToDownload, + loaded: totalBytesWritten, + total: totalBytesExpectedToDownload, + }); + } + ); + + const result = await downloadResumable.downloadAsync(); + if (result?.uri) { + await addDownloadedTrack(track.id); + return true; + } + return false; + } catch (err) { + console.warn('Failed to download track:', err); + return false; + } +} + +export async function deleteTrack(trackId: number): Promise { + try { + const filePath = getTrackFilePath(trackId); + const info = await FileSystem.getInfoAsync(filePath); + if (info.exists) { + await FileSystem.deleteAsync(filePath); + await removeDownloadedTrack(trackId); + return true; + } + return false; + } catch (err) { + console.warn('Failed to delete track:', err); + return false; + } +} + +export async function getStorageInfo(): Promise<{ used: number; total: number } | null> { + try { + const info = await FileSystem.getInfoAsync(DOCUMENTS_DIR); + if (!info.exists) return null; + + // Get all files + const files = await FileSystem.readDirectoryAsync(DOCUMENTS_DIR); + let used = 0; + + for (const file of files) { + const fileInfo = await FileSystem.getInfoAsync(`${DOCUMENTS_DIR}${file}`); + if (fileInfo.size) used += fileInfo.size; + } + + return { used, total: 5 * 1024 * 1024 * 1024 }; // 5GB limit + } catch { + return null; + } +} diff --git a/store/index.ts b/store/index.ts index 6d2951f..96f5710 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,109 +1,170 @@ -import { create } from 'zustand'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Track, User } from '../services/api'; - -// ── Player ──────────────────────────────────────────────────────────────────── - -interface PlayerState { - queue: Track[]; - index: number; - playing: boolean; - progress: number; - duration: number; - repeat: 0 | 1 | 2; - 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; - }, -})); - -// ── Auth ────────────────────────────────────────────────────────────────────── - -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 }), -})); - -// ── Likes ───────────────────────────────────────────────────────────────────── - -const LIKES_KEY = 'ns-music-likes'; - -interface LikesState { - likes: Set; - loadLikes: () => Promise; - toggleLike: (id: number) => void; - isLiked: (id: number) => boolean; -} - -export const useLikes = create((set, get) => ({ - likes: new Set(), - - loadLikes: async () => { - try { - const raw = await AsyncStorage.getItem(LIKES_KEY); - const arr: number[] = raw ? JSON.parse(raw) : []; - set({ likes: new Set(arr) }); - } catch {} - }, - - toggleLike: (id) => { - const next = new Set(get().likes); - if (next.has(id)) next.delete(id); else next.add(id); - set({ likes: next }); - AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {}); - }, - - isLiked: (id) => get().likes.has(id), -})); +import { create } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Track, User } from '../services/api'; + +// ── Offline / Downloads ─────────────────────────────────────────────────────── + +interface OfflineState { + downloadedTracks: Set; + downloading: Map; // trackId -> progress 0-1 + isOnline: boolean; + + addDownloadedTrack: (id: number) => void; + removeDownloadedTrack: (id: number) => void; + setDownloading: (id: number, progress: number) => void; + clearDownloading: (id: number) => void; + loadDownloadedTracks: () => Promise; + setOnline: (online: boolean) => void; +} + +export const useOffline = create((set, get) => ({ + downloadedTracks: new Set(), + downloading: new Map(), + isOnline: true, + + addDownloadedTrack: (id) => { + const next = new Set(get().downloadedTracks); + next.add(id); + set({ downloadedTracks: next }); + }, + + removeDownloadedTrack: (id) => { + const next = new Set(get().downloadedTracks); + next.delete(id); + set({ downloadedTracks: next }); + }, + + setDownloading: (id, progress) => { + const next = new Map(get().downloading); + if (progress >= 1) { + next.delete(id); + } else { + next.set(id, progress); + } + set({ downloading: next }); + }, + + clearDownloading: (id) => { + const next = new Map(get().downloading); + next.delete(id); + set({ downloading: next }); + }, + + loadDownloadedTracks: async () => { + try { + const { getDownloadedTracks } = await import('../services/offline'); + const ids = await getDownloadedTracks(); + set({ downloadedTracks: new Set(ids) }); + } catch (err) { + console.warn('Failed to load downloaded tracks:', err); + } + }, + + setOnline: (online) => set({ isOnline: online }), +})); + +// ── Player ──────────────────────────────────────────────────────────────────── + +interface PlayerState { + queue: Track[]; + index: number; + playing: boolean; + progress: number; + duration: number; + repeat: 0 | 1 | 2; + 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; + }, +})); + +// ── Auth ────────────────────────────────────────────────────────────────────── + +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 }), +})); + +// ── Likes ───────────────────────────────────────────────────────────────────── + +const LIKES_KEY = 'ns-music-likes'; + +interface LikesState { + likes: Set; + loadLikes: () => Promise; + toggleLike: (id: number) => void; + isLiked: (id: number) => boolean; +} + +export const useLikes = create((set, get) => ({ + likes: new Set(), + + loadLikes: async () => { + try { + const raw = await AsyncStorage.getItem(LIKES_KEY); + const arr: number[] = raw ? JSON.parse(raw) : []; + set({ likes: new Set(arr) }); + } catch {} + }, + + toggleLike: (id) => { + const next = new Set(get().likes); + if (next.has(id)) next.delete(id); else next.add(id); + set({ likes: next }); + AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {}); + }, + + isLiked: (id) => get().likes.has(id), +})); diff --git a/tsconfig.json b/tsconfig.json index 84d97cb..b1806d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ -{ - "extends": "expo/tsconfig.base", - "compilerOptions": { - "strict": true, - "baseUrl": ".", - "paths": { - "@/*": ["./*"] - } - } -} +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/workflows/build-ios.yml b/workflows/build-ios.yml index 7f38455..1fdfdd3 100644 --- a/workflows/build-ios.yml +++ b/workflows/build-ios.yml @@ -1,56 +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 +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 index 7f38455..1fdfdd3 100644 --- a/workflows/main.yml +++ b/workflows/main.yml @@ -1,56 +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 +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