Add files via upload
This commit is contained in:
parent
235427ce97
commit
73ed266994
21 changed files with 17879 additions and 1046 deletions
|
|
@ -1,102 +1,133 @@
|
|||
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: 'System',
|
||||
fontWeight: '600',
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Alert } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue, useAnimatedStyle, withTiming, withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { Colors, Radius } from '../constants/theme';
|
||||
import { useOffline } from '../store';
|
||||
import { Track, coverUrl } from '../services/api';
|
||||
|
||||
interface Props {
|
||||
track: Track;
|
||||
size?: number;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
const DL_DIR = FileSystem.documentDirectory + 'tracks/';
|
||||
|
||||
async function ensureDir() {
|
||||
const info = await FileSystem.getInfoAsync(DL_DIR);
|
||||
if (!info.exists) await FileSystem.makeDirectoryAsync(DL_DIR, { intermediates: true });
|
||||
}
|
||||
|
||||
function getLocalPath(trackId: number, filename: string) {
|
||||
const ext = filename.split('.').pop() || 'mp3';
|
||||
return `${DL_DIR}${trackId}.${ext}`;
|
||||
}
|
||||
|
||||
export default function TrackDownloadButton({ track, size = 22, showLabel = false }: Props) {
|
||||
const { downloadedTracks, downloading, setDownloading, addDownloadedTrack } = useOffline();
|
||||
const isDownloaded = downloadedTracks.has(track.id);
|
||||
const dlProgress = downloading.get(track.id);
|
||||
const isDownloading = dlProgress !== undefined;
|
||||
|
||||
const scale = useSharedValue(1);
|
||||
const scaleStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||
|
||||
const progressWidth = useAnimatedStyle(() => ({
|
||||
width: withTiming(`${(dlProgress ?? 0) * 100}%` as any, { duration: 150 }),
|
||||
}));
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
if (isDownloaded || isDownloading) return;
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
scale.value = withSpring(0.85, { damping: 8 }, () => { scale.value = withSpring(1); });
|
||||
|
||||
try {
|
||||
await ensureDir();
|
||||
const url = track.cdn_url || track.cdn_url2;
|
||||
if (!url) { Alert.alert('Ошибка', 'URL трека недоступен'); return; }
|
||||
|
||||
const localPath = getLocalPath(track.id, track.filename || `${track.id}.mp3`);
|
||||
setDownloading(track.id, 0.01);
|
||||
|
||||
const callback = (dp: FileSystem.DownloadProgressData) => {
|
||||
const pct = dp.totalBytesExpectedToWrite > 0
|
||||
? dp.totalBytesWritten / dp.totalBytesExpectedToWrite
|
||||
: 0;
|
||||
setDownloading(track.id, pct);
|
||||
};
|
||||
|
||||
const downloadResumable = FileSystem.createDownloadResumable(url, localPath, {}, callback);
|
||||
const result = await downloadResumable.downloadAsync();
|
||||
|
||||
if (result?.uri) {
|
||||
addDownloadedTrack(track.id);
|
||||
setDownloading(track.id, 1);
|
||||
// Save metadata locally
|
||||
const metaPath = `${DL_DIR}${track.id}.json`;
|
||||
await FileSystem.writeAsStringAsync(metaPath, JSON.stringify({
|
||||
id: track.id, title: track.title, artist: track.artist,
|
||||
filename: track.filename, cover_url: track.cover_url,
|
||||
localPath: result.uri,
|
||||
}));
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setDownloading(track.id, 1);
|
||||
Alert.alert('Ошибка загрузки', e?.message || 'Попробуйте снова');
|
||||
}
|
||||
}, [track, isDownloaded, isDownloading]);
|
||||
|
||||
const iconColor = isDownloaded
|
||||
? Colors.accent
|
||||
: isDownloading
|
||||
? 'rgba(255,255,255,0.4)'
|
||||
: 'rgba(255,255,255,0.7)';
|
||||
|
||||
return (
|
||||
<Animated.View style={scaleStyle}>
|
||||
<Pressable onPress={startDownload} style={s.btn} hitSlop={12}>
|
||||
{isDownloading ? (
|
||||
<View style={s.progressWrap}>
|
||||
<View style={s.progressBg}>
|
||||
<Animated.View style={[s.progressFill, progressWidth]} />
|
||||
</View>
|
||||
<Text style={s.pctText}>{Math.round((dlProgress ?? 0) * 100)}%</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ alignItems: 'center', flexDirection: 'row', gap: 4 }}>
|
||||
{isDownloaded ? (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Circle cx={12} cy={12} r={10} stroke={Colors.accent} strokeWidth={1.5} />
|
||||
<Path d="M8 12l3 3 5-5" stroke={Colors.accent} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
) : (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M12 3v13M7 11l5 5 5-5" stroke={iconColor} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M5 20h14" stroke={iconColor} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
)}
|
||||
{showLabel && (
|
||||
<Text style={[s.label, { color: iconColor }]}>
|
||||
{isDownloaded ? 'Скачано' : 'Скачать'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
btn: { alignItems: 'center', justifyContent: 'center' },
|
||||
progressWrap: { alignItems: 'center', gap: 4 },
|
||||
progressBg: { width: 48, height: 3, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.12)', overflow: 'hidden' },
|
||||
progressFill: { height: 3, backgroundColor: Colors.accent, borderRadius: 2 },
|
||||
pctText: { color: Colors.accent, fontSize: 9, fontWeight: '700' },
|
||||
label: { fontSize: 12, fontWeight: '600' },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue