Add files via upload

This commit is contained in:
Savsis 2026-05-08 02:54:57 +05:00 • committed by GitHub
parent 235427ce97
commit 73ed266994
21 changed files with 17879 additions and 1046 deletions

View file

@ -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' },
});

View file

@ -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

View 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' },
});

View 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 },
});

View 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' },
});