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' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,129 +1,162 @@
|
|||
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 (
|
||||
<PanGestureHandler onGestureEvent={gestureHandler}>
|
||||
<Animated.View
|
||||
style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
|
||||
entering={FadeInDown.springify()}
|
||||
exiting={FadeOutDown.springify()}
|
||||
>
|
||||
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<View style={[StyleSheet.absoluteFill, s.bgTint]} />
|
||||
|
||||
{/* Progress line */}
|
||||
<View style={s.progressBg}>
|
||||
<View style={[s.progressFill, { width: `${pct}%` as any }]} />
|
||||
</View>
|
||||
|
||||
<Pressable style={s.row} onPress={openPlayer} android_ripple={null}>
|
||||
<Image
|
||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||
style={s.cover}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
<View style={s.info}>
|
||||
<Text style={s.title} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||
</View>
|
||||
<View style={s.controls}>
|
||||
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}>
|
||||
<Svg width={18} height={18} 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 : Colors.text2} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
</Pressable>
|
||||
<Pressable onPress={(e) => { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}>
|
||||
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></Svg>
|
||||
</Pressable>
|
||||
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}>
|
||||
{playing
|
||||
? <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg>
|
||||
: <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>}
|
||||
</Pressable>
|
||||
<Pressable onPress={(e) => { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}>
|
||||
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
);
|
||||
}
|
||||
|
||||
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, withTiming,
|
||||
useAnimatedGestureHandler, runOnJS, interpolate, Extrapolate, Easing,
|
||||
cancelAnimation,
|
||||
} 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, Rect } from 'react-native-svg';
|
||||
import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme';
|
||||
import { usePlayer, useLikes } from '../store';
|
||||
import { togglePlay } from '../hooks/useAudio';
|
||||
import { coverUrl } from '../services/api';
|
||||
import SpectrumBars from './SpectrumBars';
|
||||
|
||||
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 openPlayer = useCallback(() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
setExpanded(true);
|
||||
translateY.value = withSpring(0, { damping: 20, stiffness: 200 });
|
||||
opacity.value = withSpring(1);
|
||||
}, [setExpanded]);
|
||||
|
||||
const gestureHandler = useAnimatedGestureHandler<any, { start: number }>({
|
||||
onStart: (_, ctx) => {
|
||||
cancelAnimation(translateY);
|
||||
ctx.start = translateY.value;
|
||||
},
|
||||
onActive: (e) => {
|
||||
if (e.translationY > 0) return; // block downward
|
||||
const dy = Math.max(e.translationY, -100);
|
||||
translateY.value = dy;
|
||||
opacity.value = interpolate(dy, [-100, 0], [0.6, 1], Extrapolate.CLAMP);
|
||||
},
|
||||
onEnd: (e) => {
|
||||
const shouldOpen = e.translationY < -40 || e.velocityY < -600;
|
||||
if (shouldOpen) {
|
||||
translateY.value = withTiming(-MINI_PLAYER_HEIGHT, {
|
||||
duration: 200, easing: Easing.out(Easing.cubic),
|
||||
}, () => runOnJS(openPlayer)());
|
||||
} else {
|
||||
translateY.value = withSpring(0, { damping: 22, stiffness: 250 });
|
||||
opacity.value = withSpring(1);
|
||||
}
|
||||
},
|
||||
onFail: () => {
|
||||
translateY.value = withSpring(0, { damping: 22, stiffness: 250 });
|
||||
opacity.value = withSpring(1);
|
||||
},
|
||||
onCancel: () => {
|
||||
translateY.value = withSpring(0, { damping: 22, stiffness: 250 });
|
||||
opacity.value = withSpring(1);
|
||||
},
|
||||
});
|
||||
|
||||
const animStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
const onNext = useCallback(() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
const ni = usePlayer.getState().nextTrack();
|
||||
usePlayer.getState().setIndex(ni);
|
||||
}, []);
|
||||
|
||||
if (!track || expanded) return null;
|
||||
|
||||
const cover = coverUrl(track.cover_url);
|
||||
const pct = duration > 0 ? (progress / duration) * 100 : 0;
|
||||
const liked = isLiked(track.id);
|
||||
|
||||
return (
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureHandler}
|
||||
activeOffsetY={[-8, 8]}
|
||||
failOffsetX={[-20, 20]}
|
||||
>
|
||||
<Animated.View style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}>
|
||||
<BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<View style={[StyleSheet.absoluteFill, s.bgTint]} />
|
||||
|
||||
{/* Progress bar */}
|
||||
<View style={s.progressBg}>
|
||||
<View style={[s.progressFill, { width: `${pct}%` as any }]} />
|
||||
</View>
|
||||
|
||||
<Pressable style={s.row} onPress={openPlayer}>
|
||||
<Image
|
||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||
style={s.cover}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
<View style={s.info}>
|
||||
<Text style={s.title} numberOfLines={1}>{track.title}</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
{playing && <SpectrumBars playing={playing} color={Colors.accent} barCount={4} height={14} />}
|
||||
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={s.controls}>
|
||||
<Pressable
|
||||
onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }}
|
||||
hitSlop={12} style={s.ctlBtn}
|
||||
>
|
||||
<Svg width={20} height={20} 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 : Colors.text} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }}
|
||||
hitSlop={12} style={s.ctlBtn}
|
||||
>
|
||||
{playing
|
||||
? <Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.text}>
|
||||
<Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.text} />
|
||||
<Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.text} />
|
||||
</Svg>
|
||||
: <Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.text}>
|
||||
<Path d="M8 5v14l11-7z" />
|
||||
</Svg>
|
||||
}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={(e) => { e.stopPropagation(); onNext(); }}
|
||||
hitSlop={12} style={s.ctlBtn}
|
||||
>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.text}>
|
||||
<Path d="M5 4l10 8-10 8V4zM19 4h-2v16h2z" />
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: { position: 'absolute', left: 8, right: 8, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderRadius: 16 },
|
||||
bgTint: { backgroundColor: 'rgba(18,18,20,0.5)' },
|
||||
progressBg: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.06)', zIndex: 2 },
|
||||
progressFill: { height: 2, backgroundColor: Colors.accent },
|
||||
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 12, paddingRight: 2, gap: 10 },
|
||||
cover: { width: 46, height: 46, borderRadius: 10, backgroundColor: Colors.elevated },
|
||||
info: { flex: 1, minWidth: 0 },
|
||||
title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||
artist: { color: 'rgba(255,255,255,0.5)', fontSize: Font.xs, marginTop: 2 },
|
||||
controls: { flexDirection: 'row', alignItems: 'center' },
|
||||
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
163
components/ShareTrackModal.tsx
Normal file
163
components/ShareTrackModal.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* ShareTrackModal — share sheet + QR for co-listening (deep link, no backend)
|
||||
* Co-listening: generates a deep link burmalda://listen?tid=123&ts=456
|
||||
* Friend opens it → app seeks to same position and plays same track
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Modal, Alert } from 'react-native';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import QRCode from 'react-native-qrcode-svg';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Image } from 'expo-image';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { Colors, Font, Radius } from '../constants/theme';
|
||||
import { Track, coverUrl } from '../services/api';
|
||||
|
||||
interface Props {
|
||||
track: Track;
|
||||
progress: number;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function buildDeepLink(trackId: number, position: number) {
|
||||
return `burmalda://listen?tid=${trackId}&ts=${Math.floor(position)}`;
|
||||
}
|
||||
|
||||
export default function ShareTrackModal({ track, progress, visible, onClose }: Props) {
|
||||
const [showQR, setShowQR] = useState(false);
|
||||
const cover = coverUrl(track.cover_url);
|
||||
const deepLink = buildDeepLink(track.id, progress);
|
||||
const shareText = `${track.artist} — ${track.title}\n${deepLink}`;
|
||||
|
||||
const handleShare = async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
try {
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(deepLink, {
|
||||
dialogTitle: `${track.artist} — ${track.title}`,
|
||||
});
|
||||
} else {
|
||||
await Clipboard.setStringAsync(shareText);
|
||||
Alert.alert('Скопировано', 'Ссылка скопирована в буфер');
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
await Clipboard.setStringAsync(deepLink);
|
||||
Alert.alert('Скопировано', 'Ссылка скопирована');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<Pressable style={s.backdrop} onPress={onClose} />
|
||||
<BlurView intensity={40} tint="dark" style={s.sheet}>
|
||||
<View style={s.handle} />
|
||||
|
||||
{/* Track info */}
|
||||
<View style={s.trackRow}>
|
||||
{cover
|
||||
? <Image source={{ uri: cover }} style={s.cover} contentFit="cover" />
|
||||
: <View style={[s.cover, { backgroundColor: Colors.elevated }]} />
|
||||
}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={s.title} numberOfLines={1}>{track.title}</Text>
|
||||
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* QR toggle */}
|
||||
{showQR && (
|
||||
<View style={s.qrWrap}>
|
||||
<QRCode
|
||||
value={deepLink}
|
||||
size={180}
|
||||
backgroundColor="transparent"
|
||||
color={Colors.text}
|
||||
/>
|
||||
<Text style={s.qrHint}>Друг сканирует — сразу переходит к этому месту</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<View style={s.actions}>
|
||||
<ActionBtn
|
||||
icon="share"
|
||||
label="Поделиться"
|
||||
onPress={handleShare}
|
||||
/>
|
||||
<ActionBtn
|
||||
icon="copy"
|
||||
label="Скопировать ссылку"
|
||||
onPress={handleCopy}
|
||||
/>
|
||||
<ActionBtn
|
||||
icon="qr"
|
||||
label={showQR ? 'Скрыть QR' : 'QR-код'}
|
||||
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setShowQR(v => !v); }}
|
||||
active={showQR}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={s.hint}>
|
||||
Совместное прослушивание: друг откроет ссылку и приложение перейдёт к этому треку и позиции
|
||||
</Text>
|
||||
|
||||
<Pressable style={s.closeBtn} onPress={onClose}>
|
||||
<Text style={s.closeTxt}>Закрыть</Text>
|
||||
</Pressable>
|
||||
</BlurView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBtn({ icon, label, onPress, active = false }: {
|
||||
icon: 'share' | 'copy' | 'qr'; label: string; onPress: () => void; active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={[s.actionBtn, active && s.actionBtnActive]}>
|
||||
<View style={s.actionIcon}>
|
||||
{icon === 'share' && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13" stroke={active ? Colors.accent : Colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
)}
|
||||
{icon === 'copy' && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2M16 8h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-2" stroke={active ? Colors.accent : Colors.text} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
)}
|
||||
{icon === 'qr' && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M3 3h6v6H3zM15 3h6v6h-6zM3 15h6v6H3zM15 15h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM15 19h2v2h-2zM19 19h2v2h-2z" stroke={active ? Colors.accent : Colors.text} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[s.actionLabel, active && { color: Colors.accent }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
backdrop: { flex: 1 },
|
||||
sheet: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.08)' },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', alignSelf: 'center', marginBottom: 20 },
|
||||
trackRow: { flexDirection: 'row', alignItems: 'center', gap: 14, marginBottom: 24 },
|
||||
cover: { width: 52, height: 52, borderRadius: Radius.sm },
|
||||
title: { color: Colors.text, fontSize: Font.md, fontWeight: '700' },
|
||||
artist: { color: 'rgba(255,255,255,0.55)', fontSize: Font.sm, marginTop: 2 },
|
||||
qrWrap: { alignItems: 'center', marginBottom: 24, padding: 20, backgroundColor: 'rgba(255,255,255,0.06)', borderRadius: 16 },
|
||||
qrHint: { color: 'rgba(255,255,255,0.45)', fontSize: 12, marginTop: 12, textAlign: 'center' },
|
||||
actions: { flexDirection: 'row', gap: 10, marginBottom: 16 },
|
||||
actionBtn: { flex: 1, alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14, paddingVertical: 14, paddingHorizontal: 8 },
|
||||
actionBtnActive: { backgroundColor: `${Colors.accent}20`, borderWidth: 1, borderColor: `${Colors.accent}50` },
|
||||
actionIcon: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||
actionLabel: { color: Colors.text, fontSize: 11, fontWeight: '600', textAlign: 'center' },
|
||||
hint: { color: 'rgba(255,255,255,0.3)', fontSize: 11, textAlign: 'center', lineHeight: 16, marginBottom: 20 },
|
||||
closeBtn: { paddingVertical: 14, alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14 },
|
||||
closeTxt: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||
});
|
||||
73
components/SpectrumBars.tsx
Normal file
73
components/SpectrumBars.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* SpectrumBars — animated equalizer bars for MiniPlayer / Player
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue, useAnimatedStyle, withRepeat, withSequence,
|
||||
withTiming, Easing, cancelAnimation,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface Props {
|
||||
playing: boolean;
|
||||
color?: string;
|
||||
barCount?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const DURATIONS = [420, 350, 500, 380, 460];
|
||||
const HEIGHTS = [0.4, 0.7, 0.55, 0.85, 0.5];
|
||||
|
||||
function Bar({ playing, color, maxH, duration, minRatio }: {
|
||||
playing: boolean; color: string; maxH: number; duration: number; minRatio: number;
|
||||
}) {
|
||||
const h = useSharedValue(maxH * minRatio);
|
||||
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
h.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(maxH, { duration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(maxH * 0.2, { duration, easing: Easing.inOut(Easing.ease) }),
|
||||
),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
cancelAnimation(h);
|
||||
h.value = withTiming(maxH * 0.25, { duration: 300 });
|
||||
}
|
||||
}, [playing]);
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
height: h.value,
|
||||
backgroundColor: color,
|
||||
borderRadius: 2,
|
||||
width: 3,
|
||||
}));
|
||||
|
||||
return <Animated.View style={style} />;
|
||||
}
|
||||
|
||||
export default function SpectrumBars({ playing, color = '#ffffff', barCount = 4, height = 18 }: Props) {
|
||||
const bars = Array.from({ length: barCount }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<View style={[s.wrap, { height }]}>
|
||||
{bars.map((i) => (
|
||||
<Bar
|
||||
key={i}
|
||||
playing={playing}
|
||||
color={color}
|
||||
maxH={height * (HEIGHTS[i % HEIGHTS.length])}
|
||||
duration={DURATIONS[i % DURATIONS.length]}
|
||||
minRatio={0.2}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 },
|
||||
});
|
||||
81
components/WaveAnimation.tsx
Normal file
81
components/WaveAnimation.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* WaveAnimation — flowing wave bars used in "My Wave" header
|
||||
* Simulates audio wave / signal motion with staggered sine animation
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue, useAnimatedStyle, withRepeat, withSequence,
|
||||
withTiming, Easing, cancelAnimation,
|
||||
} from 'react-native-reanimated';
|
||||
import { Colors } from '../constants/theme';
|
||||
|
||||
interface Props {
|
||||
active?: boolean;
|
||||
barCount?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const PHASES = [0, 0.4, 0.8, 0.2, 0.6, 0.1, 0.7, 0.3, 0.9, 0.5,
|
||||
0.15, 0.55, 0.85, 0.25, 0.65];
|
||||
const BASE_DURATION = 900;
|
||||
|
||||
function WaveBar({ active, maxH, phase, color }: {
|
||||
active: boolean; maxH: number; phase: number; color: string;
|
||||
}) {
|
||||
const h = useSharedValue(maxH * 0.15);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
const delay = Math.round(phase * BASE_DURATION);
|
||||
h.value = withSequence(
|
||||
withTiming(maxH * 0.15, { duration: delay }),
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(maxH, { duration: BASE_DURATION, easing: Easing.inOut(Easing.sin) }),
|
||||
withTiming(maxH * 0.1, { duration: BASE_DURATION, easing: Easing.inOut(Easing.sin) }),
|
||||
),
|
||||
-1,
|
||||
false,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
cancelAnimation(h);
|
||||
h.value = withTiming(maxH * 0.12, { duration: 400 });
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
height: h.value,
|
||||
backgroundColor: color,
|
||||
borderRadius: 3,
|
||||
flex: 1,
|
||||
marginHorizontal: 1.5,
|
||||
}));
|
||||
|
||||
return <Animated.View style={style} />;
|
||||
}
|
||||
|
||||
export default function WaveAnimation({ active = true, barCount = 40, height = 56, color }: Props) {
|
||||
const bars = Array.from({ length: barCount }, (_, i) => i);
|
||||
const barColor = color ?? Colors.accent;
|
||||
|
||||
return (
|
||||
<View style={[s.wrap, { height }]}>
|
||||
{bars.map((i) => (
|
||||
<WaveBar
|
||||
key={i}
|
||||
active={active}
|
||||
maxH={height}
|
||||
phase={PHASES[i % PHASES.length]}
|
||||
color={`${barColor}${Math.round(40 + (i % 5) * 24).toString(16).padStart(2, '0')}`}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: { flexDirection: 'row', alignItems: 'flex-end', overflow: 'hidden' },
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue