Add files via upload
This commit is contained in:
parent
d612a65f81
commit
5748d2d13c
31 changed files with 3197 additions and 2492 deletions
1
app.json
1
app.json
|
|
@ -19,6 +19,7 @@
|
|||
"infoPlist": {
|
||||
"UIBackgroundModes": ["audio"],
|
||||
"NSMicrophoneUsageDescription": "Не используется",
|
||||
"NSFileProtectionComplete": "NSFileProtectionCompleteUnlessOpen",
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["burmalda"]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl } from 'react-native';
|
||||
import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl, ActivityIndicator } from 'react-native';
|
||||
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||
import { 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 { usePlayer, useLikes } from '../../store';
|
||||
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;
|
||||
|
|
@ -16,17 +20,49 @@ export default function HomeScreen() {
|
|||
const [tracks, setTracks] = useState<Track[]>([]);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
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 () => {
|
||||
const [t, a] = await Promise.all([getTracks(), getAlbums()]);
|
||||
setTracks(t);
|
||||
setAlbums(a);
|
||||
// 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();
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useFocusEffect(() => {
|
||||
loadDownloadedTracks();
|
||||
});
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefresh(true);
|
||||
|
|
@ -40,6 +76,14 @@ export default function HomeScreen() {
|
|||
usePlayer.getState().setExpanded(true);
|
||||
};
|
||||
|
||||
if (loading && tracks.length === 0) {
|
||||
return (
|
||||
<View style={[styles.scroll, { justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<ActivityIndicator size="large" color={Colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
|
|
@ -152,8 +196,35 @@ 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 (
|
||||
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
|
||||
|
|
@ -168,6 +239,14 @@ function TrackRow({ track, index, liked, onPress, onLike }: {
|
|||
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : Colors.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
</Pressable>
|
||||
<DownloadButton
|
||||
trackId={track.id}
|
||||
isDownloaded={isDownloaded}
|
||||
downloadProgress={downloadProgress}
|
||||
onDownload={handleDownload}
|
||||
onDelete={handleDownload}
|
||||
size="sm"
|
||||
/>
|
||||
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
|
@ -190,13 +269,13 @@ const styles = StyleSheet.create({
|
|||
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 },
|
||||
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 },
|
||||
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)' },
|
||||
|
|
|
|||
85
build.yml
Normal file
85
build.yml
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
name: Build & Deploy iOS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
EXPO_VERSION: 'latest'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Expo
|
||||
run: npm install -g expo-cli eas-cli
|
||||
|
||||
- name: Lint & Type Check
|
||||
run: |
|
||||
npm run type-check 2>/dev/null || true
|
||||
npm run lint 2>/dev/null || true
|
||||
|
||||
- name: Build Preview (EAS)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
||||
env:
|
||||
EAS_TOKEN: ${{ secrets.EAS_TOKEN }}
|
||||
run: |
|
||||
eas build --platform ios --profile preview --non-interactive --wait
|
||||
|
||||
- name: Build Production (EAS)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
EAS_TOKEN: ${{ secrets.EAS_TOKEN }}
|
||||
run: |
|
||||
eas build --platform ios --profile production --non-interactive --wait
|
||||
|
||||
- name: Release Notes
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "### Build Complete ✅" >> $GITHUB_STEP_SUMMARY
|
||||
echo "App version: $(cat package.json | grep version | head -1 | awk -F= '{ print $2 }' | sed 's/[version",]//g')" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Built for: iOS" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Download IPA from EAS Dashboard" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check TypeScript
|
||||
run: npx tsc --noEmit || true
|
||||
|
||||
- name: Validate structure
|
||||
run: |
|
||||
test -f app.json && echo "✓ app.json exists"
|
||||
test -f tsconfig.json && echo "✓ tsconfig.json exists"
|
||||
test -d app && echo "✓ app directory exists"
|
||||
test -d services && echo "✓ services directory exists"
|
||||
test -d components && echo "✓ components directory exists"
|
||||
101
components/DownloadButton.tsx
Normal file
101
components/DownloadButton.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { View, StyleSheet, Pressable, Text, ActivityIndicator } from 'react-native';
|
||||
import Animated, { FadeIn, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||
import { useMemo } from 'react';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Colors, Font } from '../constants/theme';
|
||||
|
||||
export interface DownloadButtonProps {
|
||||
trackId: number;
|
||||
isDownloaded?: boolean;
|
||||
downloadProgress?: number; // 0-1
|
||||
onDownload?: () => void;
|
||||
onDelete?: () => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export default function DownloadButton({
|
||||
trackId,
|
||||
isDownloaded = false,
|
||||
downloadProgress = 0,
|
||||
onDownload,
|
||||
onDelete,
|
||||
size = 'md',
|
||||
}: DownloadButtonProps) {
|
||||
const sizing = useMemo(() => {
|
||||
const map = { sm: 20, md: 24, lg: 32 };
|
||||
return map[size];
|
||||
}, [size]);
|
||||
|
||||
const handlePress = async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
if (isDownloaded) {
|
||||
onDelete?.();
|
||||
} else {
|
||||
onDownload?.();
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = downloadProgress > 0 && downloadProgress < 1;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Animated.View entering={FadeIn}>
|
||||
<View style={[styles.container, { width: sizing, height: sizing }]}>
|
||||
<ActivityIndicator color={Colors.accent} size={sizing * 0.6} />
|
||||
{downloadProgress > 0.2 && (
|
||||
<Text style={[styles.progress, { fontSize: sizing * 0.3 }]}>
|
||||
{Math.round(downloadProgress * 100)}%
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
{
|
||||
width: sizing,
|
||||
height: sizing,
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{isDownloaded ? (
|
||||
<Svg width={sizing} height={sizing} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"
|
||||
fill={Colors.accent}
|
||||
/>
|
||||
</Svg>
|
||||
) : (
|
||||
<Svg width={sizing} height={sizing} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"
|
||||
fill={Colors.text2}
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
progress: {
|
||||
color: Colors.accent,
|
||||
fontFamily: Font.family.medium,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView } from 'react-native';
|
||||
import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView, ViewStyle } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue, useAnimatedStyle, withSpring,
|
||||
useAnimatedGestureHandler, runOnJS, FadeIn, interpolate,
|
||||
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';
|
||||
|
|
@ -10,14 +10,16 @@ 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 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 } = Dimensions.get('window');
|
||||
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 {
|
||||
|
|
@ -34,16 +36,19 @@ export default function Player() {
|
|||
const [lyricsLoading, setLyricsLoading] = useState(false);
|
||||
const lyricsScrollRef = useRef<ScrollView>(null);
|
||||
const lineHeights = useRef<number[]>([]);
|
||||
const gestureRef = useRef<any>(null);
|
||||
|
||||
const translateY = useSharedValue(0);
|
||||
const coverScale = useSharedValue(1);
|
||||
const coverRotate = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 });
|
||||
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 () => {
|
||||
|
|
@ -64,21 +69,69 @@ export default function Player() {
|
|||
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 });
|
||||
lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 100), animated: true });
|
||||
}, [currentLineIdx, lyricsOpen]);
|
||||
|
||||
const gestureHandler = useAnimatedGestureHandler<any, { startY: number }>({
|
||||
onStart: (_, ctx) => { ctx.startY = translateY.value; },
|
||||
onActive: (e, ctx) => { if (e.translationY > 0) translateY.value = ctx.startY + e.translationY; },
|
||||
const gestureHandler = useAnimatedGestureHandler<any, { startY: number, startX: number }>({
|
||||
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) => {
|
||||
if (e.translationY > 100 || e.velocityY > 700) runOnJS(close)();
|
||||
else translateY.value = withSpring(0, { damping: 20 });
|
||||
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, 300], [1, 0]) }));
|
||||
const coverStyle = useAnimatedStyle(() => ({ transform: [{ scale: coverScale.value }] }));
|
||||
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);
|
||||
|
|
@ -106,48 +159,148 @@ export default function Player() {
|
|||
|
||||
return (
|
||||
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}>
|
||||
{/* Blurred background */}
|
||||
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={80} />
|
||||
{/* Blurred background with better gradient */}
|
||||
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={100} />
|
||||
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
|
||||
<BlurView intensity={20} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<BlurView intensity={25} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
|
||||
<PanGestureHandler onGestureEvent={gestureHandler}>
|
||||
<PanGestureHandler ref={gestureRef} onGestureEvent={gestureHandler}>
|
||||
<Animated.View style={[s.sheet, containerStyle, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 16 }]}>
|
||||
|
||||
{/* Top bar */}
|
||||
{/* Top bar with better design */}
|
||||
<View style={s.topBar}>
|
||||
<Text style={s.topLabel}>Сейчас играет</Text>
|
||||
<Pressable onPress={close} hitSlop={16} style={s.closeBtn}>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke="rgba(255,255,255,0.5)" strokeWidth={2} strokeLinecap="round" />
|
||||
<Pressable onPress={close} hitSlop={20} style={s.closeBtn}>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke="rgba(255,255,255,0.6)" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Cover */}
|
||||
{/* Cover with better shadow */}
|
||||
<View style={s.coverWrap}>
|
||||
<Animated.View style={[s.coverShadow, coverStyle]}>
|
||||
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={s.cover} contentFit="cover" transition={300} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Meta */}
|
||||
{/* Meta with better spacing */}
|
||||
<View style={s.metaRow}>
|
||||
<View style={s.metaText}>
|
||||
<Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={12}>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
||||
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : 'rgba(255,255,255,0.4)'} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Pressable onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={16}>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
||||
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke={liked ? Colors.pink : 'rgba(255,255,255,0.5)'} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Seek */}
|
||||
{/* Seek with gradient track */}
|
||||
<View style={s.seekWrap}>
|
||||
<Slider style={{ width: '100%', height: 32 }} minimumValue={0} maximumValue={1} value={pct} minimumTrackTintColor={Colors.text} maximumTrackTintColor="rgba(255,255,255,0.2)" thumbTintColor={Colors.text} onSlidingComplete={(v) => seekTo(v * duration)} />
|
||||
<View style={s.sliderContainer}>
|
||||
<Slider
|
||||
style={{ flex: 1, height: 40 }}
|
||||
minimumValue={0}
|
||||
maximumValue={1}
|
||||
value={pct}
|
||||
minimumTrackTintColor={Colors.accent}
|
||||
maximumTrackTintColor="rgba(255,255,255,0.15)"
|
||||
thumbTintColor={Colors.text}
|
||||
onSlidingComplete={(v) => seekTo(v * duration)}
|
||||
/>
|
||||
</View>
|
||||
<View style={s.timeRow}>
|
||||
<Text style={s.timeText}>{fmt(progress)}</Text>
|
||||
<Text style={s.timeText}>{fmt(duration - progress)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Controls with better styling */}
|
||||
<View style={s.controls}>
|
||||
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
|
||||
<ShuffleIcon active={shuffle} />
|
||||
</CtlBtn>
|
||||
<CtlBtn onPress={onPrev}>
|
||||
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
|
||||
<Rect x={5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
|
||||
<Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} />
|
||||
</Svg>
|
||||
</CtlBtn>
|
||||
<Pressable style={s.playBtn} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}>
|
||||
{playing
|
||||
? <Svg width={28} height={28} viewBox="0 0 24 24"><Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /><Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} /></Svg>
|
||||
: <Svg width={28} height={28} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" /></Svg>}
|
||||
</Pressable>
|
||||
<CtlBtn onPress={onNext}>
|
||||
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
|
||||
<Rect x={20.5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
|
||||
<Path d="M6 6l12.5 8L6 22V6z" fill={Colors.text} />
|
||||
</Svg>
|
||||
</CtlBtn>
|
||||
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
|
||||
<RepeatIcon repeat={repeat} />
|
||||
</CtlBtn>
|
||||
</View>
|
||||
|
||||
{/* Lyrics button */}
|
||||
<View style={s.bottomRow}>
|
||||
<Pressable style={[s.lyrBtn, lyricsOpen && s.lyrBtnActive]} onPress={loadLyrics}>
|
||||
<Svg width={15} height={15} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.5)'} strokeWidth={2} viewBox="0 0 24 24">
|
||||
<Line x1={3} y1={8} x2={21} y2={8} strokeLinecap="round" /><Line x1={3} y1={12} x2={21} y2={12} strokeLinecap="round" /><Line x1={3} y1={16} x2={15} y2={16} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[s.lyrBtnText, lyricsOpen && { color: Colors.accent }]}>
|
||||
{lyricsLoading ? 'Загрузка…' : 'Текст'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
|
||||
{/* Lyrics sheet with better design */}
|
||||
{lyricsOpen && (
|
||||
<View style={s.lyrSheet}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={() => setLyricsOpen(false)} />
|
||||
<BlurView intensity={50} tint="dark" style={s.lyrInner}>
|
||||
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
|
||||
<View style={s.lyrHandle} />
|
||||
</Pressable>
|
||||
<View style={s.lyrHeader}>
|
||||
{cover
|
||||
? <Image source={{ uri: cover }} style={s.lyrThumb} contentFit="cover" />
|
||||
: <View style={[s.lyrThumb, { backgroundColor: Colors.elevated }]} />}
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={s.lyrTrackTitle} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={s.lyrArtist} numberOfLines={1}>{track.artist}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView ref={lyricsScrollRef} style={s.lyrScroll} contentContainerStyle={{ paddingVertical: 40, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false} scrollEventThrottle={16}>
|
||||
{lrcLines.length > 0
|
||||
? lrcLines.map((line, i) => (
|
||||
<Animated.Text
|
||||
key={i}
|
||||
style={[
|
||||
s.lyrLine,
|
||||
i === currentLineIdx && s.lyrLineActive,
|
||||
i < currentLineIdx && s.lyrLinePassed
|
||||
]}
|
||||
onLayout={(e) => { lineHeights.current[i] = e.nativeEvent.layout.height; }}
|
||||
>
|
||||
{line.text}
|
||||
</Animated.Text>
|
||||
))
|
||||
: plainLyrics
|
||||
? <Text style={s.plainLyrics}>{plainLyrics}</Text>
|
||||
: <Text style={s.lyrEmpty}>Текст не найден</Text>}
|
||||
</ScrollView>
|
||||
</BlurView>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
<Text style={s.timeText}>{fmt(progress)}</Text>
|
||||
<Text style={s.timeText}>{fmt(duration)}</Text>
|
||||
</View>
|
||||
|
|
@ -235,7 +388,13 @@ function CtlBtn({ onPress, active = false, children }: { onPress: () => void; ac
|
|||
const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] }));
|
||||
return (
|
||||
<Animated.View style={st}>
|
||||
<Pressable onPress={onPress} onPressIn={() => { sc.value = withSpring(0.82, { damping: 12 }); }} onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} hitSlop={12} style={[s.ctlBtn, active && s.ctlBtnActive]}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={() => { sc.value = withSpring(0.75, { damping: 10, mass: 0.8 }); }}
|
||||
onPressOut={() => { sc.value = withSpring(1, { damping: 10, mass: 0.8 }); }}
|
||||
hitSlop={14}
|
||||
style={[s.ctlBtn, active && s.ctlBtnActive]}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
|
@ -243,26 +402,26 @@ function CtlBtn({ onPress, active = false, children }: { onPress: () => void; ac
|
|||
}
|
||||
|
||||
function ShuffleIcon({ active }: { active: boolean }) {
|
||||
const c = active ? Colors.accent : 'rgba(255,255,255,0.5)';
|
||||
const c = active ? Colors.accent : 'rgba(255,255,255,0.6)';
|
||||
return (
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M4 20L21 3" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="21,16 21,21 16,21" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M4 20L21 3" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="21,16 21,21 16,21" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
|
||||
const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.5)';
|
||||
const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.6)';
|
||||
return (
|
||||
<View>
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M3 11V9a4 4 0 0 1 4-4h14" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M3 11V9a4 4 0 0 1 4-4h14" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
{repeat === 2 && <View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>}
|
||||
</View>
|
||||
|
|
@ -275,42 +434,47 @@ function fmt(sec: number) {
|
|||
}
|
||||
|
||||
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 },
|
||||
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' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import TrackPlayer, {
|
|||
useProgress,
|
||||
} from 'react-native-track-player';
|
||||
import { usePlayer } from '../store';
|
||||
import { coverUrl, Track } from '../services/api';
|
||||
import { coverUrl, Track, getTrackUrl } from '../services/api';
|
||||
|
||||
let _setupDone = false;
|
||||
let _lastLoadedId: number | null = null;
|
||||
|
|
@ -43,14 +43,16 @@ async function ensureSetup() {
|
|||
}
|
||||
|
||||
// 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: track.cdn_url2 || track.cdn_url,
|
||||
url: url || (track.cdn_url2 || track.cdn_url),
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
artwork: coverUrl(track.cover_url) ?? undefined,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,23 @@ export function coverUrl(raw: string | null): string | null {
|
|||
return `${BASE}/api/music/cover/${raw}`;
|
||||
}
|
||||
|
||||
// ── Offline/Local playback ────────────────────────────────────────────────────
|
||||
|
||||
export async function getTrackUrl(track: Track): Promise<string> {
|
||||
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 {
|
||||
|
|
|
|||
193
services/offline.ts
Normal file
193
services/offline.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import * as FileSystem from 'expo-file-system';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Track, Album } from './api';
|
||||
|
||||
const OFFLINE_TRACKS_KEY = 'offline_tracks_cache';
|
||||
const OFFLINE_ALBUMS_KEY = 'offline_albums_cache';
|
||||
const DOWNLOADED_TRACKS_KEY = 'downloaded_tracks';
|
||||
|
||||
const DOCUMENTS_DIR = `${FileSystem.documentDirectory}music/`;
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function initOfflineStorage() {
|
||||
try {
|
||||
const dir = await FileSystem.getInfoAsync(DOCUMENTS_DIR);
|
||||
if (!dir.exists) {
|
||||
await FileSystem.makeDirectoryAsync(DOCUMENTS_DIR, { intermediates: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to create offline storage dir:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cache management ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function cacheTracksAndAlbums(tracks: Track[], albums: Album[]) {
|
||||
try {
|
||||
await Promise.all([
|
||||
AsyncStorage.setItem(OFFLINE_TRACKS_KEY, JSON.stringify(tracks)),
|
||||
AsyncStorage.setItem(OFFLINE_ALBUMS_KEY, JSON.stringify(albums)),
|
||||
]);
|
||||
} catch (err) {
|
||||
console.warn('Failed to cache tracks and albums:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedTracks(): Promise<Track[] | null> {
|
||||
try {
|
||||
const cached = await AsyncStorage.getItem(OFFLINE_TRACKS_KEY);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
} catch (err) {
|
||||
console.warn('Failed to get cached tracks:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedAlbums(): Promise<Album[] | null> {
|
||||
try {
|
||||
const cached = await AsyncStorage.getItem(OFFLINE_ALBUMS_KEY);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
} catch (err) {
|
||||
console.warn('Failed to get cached albums:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Downloaded tracks management ───────────────────────────────────────────────
|
||||
|
||||
export async function getDownloadedTracks(): Promise<number[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(DOWNLOADED_TRACKS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addDownloadedTrack(trackId: number) {
|
||||
try {
|
||||
const ids = await getDownloadedTracks();
|
||||
if (!ids.includes(trackId)) {
|
||||
ids.push(trackId);
|
||||
await AsyncStorage.setItem(DOWNLOADED_TRACKS_KEY, JSON.stringify(ids));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to add downloaded track:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDownloadedTrack(trackId: number) {
|
||||
try {
|
||||
let ids = await getDownloadedTracks();
|
||||
ids = ids.filter(id => id !== trackId);
|
||||
await AsyncStorage.setItem(DOWNLOADED_TRACKS_KEY, JSON.stringify(ids));
|
||||
} catch (err) {
|
||||
console.warn('Failed to remove downloaded track:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrackFilePath(trackId: number): string {
|
||||
return `${DOCUMENTS_DIR}track_${trackId}.mp3`;
|
||||
}
|
||||
|
||||
export async function isTrackDownloaded(trackId: number): Promise<boolean> {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(getTrackFilePath(trackId));
|
||||
return info.exists;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Track download ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DownloadProgress {
|
||||
trackId: number;
|
||||
progress: number;
|
||||
loaded: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function downloadTrack(
|
||||
track: Track,
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const filePath = getTrackFilePath(track.id);
|
||||
const uri = track.cdn_url || track.cdn_url2;
|
||||
|
||||
if (!uri) {
|
||||
console.warn('No download URL for track:', track.id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if already downloaded
|
||||
if (await isTrackDownloaded(track.id)) {
|
||||
await addDownloadedTrack(track.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Download with progress tracking
|
||||
const downloadResumable = FileSystem.createDownloadResumable(
|
||||
uri,
|
||||
filePath,
|
||||
{},
|
||||
(progress) => {
|
||||
const { totalBytesWritten, totalBytesExpectedToDownload } = progress;
|
||||
onProgress?.({
|
||||
trackId: track.id,
|
||||
progress: totalBytesWritten / totalBytesExpectedToDownload,
|
||||
loaded: totalBytesWritten,
|
||||
total: totalBytesExpectedToDownload,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const result = await downloadResumable.downloadAsync();
|
||||
if (result?.uri) {
|
||||
await addDownloadedTrack(track.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.warn('Failed to download track:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTrack(trackId: number): Promise<boolean> {
|
||||
try {
|
||||
const filePath = getTrackFilePath(trackId);
|
||||
const info = await FileSystem.getInfoAsync(filePath);
|
||||
if (info.exists) {
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
await removeDownloadedTrack(trackId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.warn('Failed to delete track:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStorageInfo(): Promise<{ used: number; total: number } | null> {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(DOCUMENTS_DIR);
|
||||
if (!info.exists) return null;
|
||||
|
||||
// Get all files
|
||||
const files = await FileSystem.readDirectoryAsync(DOCUMENTS_DIR);
|
||||
let used = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(`${DOCUMENTS_DIR}${file}`);
|
||||
if (fileInfo.size) used += fileInfo.size;
|
||||
}
|
||||
|
||||
return { used, total: 5 * 1024 * 1024 * 1024 }; // 5GB limit
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,67 @@ 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<number>;
|
||||
downloading: Map<number, number>; // 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<void>;
|
||||
setOnline: (online: boolean) => void;
|
||||
}
|
||||
|
||||
export const useOffline = create<OfflineState>((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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue