Add files via upload
This commit is contained in:
parent
235427ce97
commit
73ed266994
21 changed files with 17879 additions and 1046 deletions
6
app.json
6
app.json
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Burmalda Music",
|
"name": "NodeShiftMusic",
|
||||||
"slug": "burmalda-music",
|
"slug": "nodeshiftmusic",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "space.nodeshift.burmalda",
|
"bundleIdentifier": "com.nodeshiftmusic",
|
||||||
"buildNumber": "1",
|
"buildNumber": "1",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIBackgroundModes": ["audio"],
|
"UIBackgroundModes": ["audio"],
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { View, StyleSheet, ActivityIndicator } from 'react-native';
|
import { View, StyleSheet, ActivityIndicator, Linking } from 'react-native';
|
||||||
import { Tabs, router } from 'expo-router';
|
import { Tabs, router } from 'expo-router';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
|
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
|
||||||
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
|
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
|
||||||
import { useAuth, usePlayer, useLikes } from '../../store';
|
import { useAuth, usePlayer, useLikes, useHidden, usePlaylists } from '../../store';
|
||||||
import { useAudio } from '../../hooks/useAudio';
|
import { useAudio } from '../../hooks/useAudio';
|
||||||
import MiniPlayer from '../../components/MiniPlayer';
|
import MiniPlayer from '../../components/MiniPlayer';
|
||||||
import Player from '../../components/Player';
|
import Player from '../../components/Player';
|
||||||
|
|
@ -13,6 +13,8 @@ export default function TabsLayout() {
|
||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
const { expanded } = usePlayer();
|
const { expanded } = usePlayer();
|
||||||
const { loadLikes } = useLikes();
|
const { loadLikes } = useLikes();
|
||||||
|
const { loadHidden } = useHidden();
|
||||||
|
const { loadPlaylists } = usePlaylists();
|
||||||
useAudio(); // single audio controller for the whole app
|
useAudio(); // single audio controller for the whole app
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -20,9 +22,33 @@ export default function TabsLayout() {
|
||||||
}, [user, ready]);
|
}, [user, ready]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) loadLikes();
|
if (user) { loadLikes(); loadHidden(); loadPlaylists(); }
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// Handle deep link: burmalda://listen?tid=123&ts=45
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUrl = (event: { url: string }) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(event.url);
|
||||||
|
if (url.pathname === '//listen' || url.host === 'listen') {
|
||||||
|
const tid = Number(url.searchParams.get('tid'));
|
||||||
|
const ts = Number(url.searchParams.get('ts') ?? '0');
|
||||||
|
if (!tid) return;
|
||||||
|
const { queue } = usePlayer.getState();
|
||||||
|
const trackIdx = queue.findIndex(t => t.id === tid);
|
||||||
|
if (trackIdx >= 0) {
|
||||||
|
usePlayer.getState().setIndex(trackIdx);
|
||||||
|
usePlayer.getState().setExpanded(true);
|
||||||
|
setTimeout(() => { import('../../hooks/useAudio').then(m => m.seekTo(ts)); }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
const sub = Linking.addEventListener('url', handleUrl);
|
||||||
|
Linking.getInitialURL().then(url => { if (url) handleUrl({ url }); });
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!ready) return (
|
if (!ready) return (
|
||||||
<View style={{ flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<ActivityIndicator color={Colors.accent} />
|
<ActivityIndicator color={Colors.accent} />
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api
|
||||||
import { downloadTrack, deleteTrack, isTrackDownloaded, DownloadProgress, cacheTracksAndAlbums, getCachedTracks, getCachedAlbums, initOfflineStorage } from '../../services/offline';
|
import { downloadTrack, deleteTrack, isTrackDownloaded, DownloadProgress, cacheTracksAndAlbums, getCachedTracks, getCachedAlbums, initOfflineStorage } from '../../services/offline';
|
||||||
import { usePlayer, useLikes, useOffline } from '../../store';
|
import { usePlayer, useLikes, useOffline } from '../../store';
|
||||||
import CoverImage from '../../components/CoverImage';
|
import CoverImage from '../../components/CoverImage';
|
||||||
import DownloadButton from '../../components/DownloadButton';
|
import TrackDownloadButton from '../../components/DownloadButton';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const CARD_W = (width - Spacing.md * 3) / 2;
|
const CARD_W = (width - Spacing.md * 3) / 2;
|
||||||
|
|
@ -196,35 +196,9 @@ function TrackRow({ track, index, liked, onPress, onLike }: {
|
||||||
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
|
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { queue, index: qi } = usePlayer();
|
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 active = queue[qi]?.id === track.id;
|
||||||
const cover = coverUrl(track.cover_url);
|
const cover = coverUrl(track.cover_url);
|
||||||
const downloadProgress = downloading.get(track.id) || 0;
|
const ext = (track.filename?.split('.').pop() || 'MP3').toUpperCase();
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
|
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
|
||||||
|
|
@ -232,21 +206,17 @@ function TrackRow({ track, index, liked, onPress, onLike }: {
|
||||||
<CoverImage uri={cover} title={track.title} size={44} radius={Radius.sm} />
|
<CoverImage uri={cover} title={track.title} size={44} radius={Radius.sm} />
|
||||||
<View style={styles.trackInfo}>
|
<View style={styles.trackInfo}>
|
||||||
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 2 }}>
|
||||||
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
|
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
<View style={styles.extBadge}><Text style={styles.extText}>{ext}</Text></View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={onLike} hitSlop={12}>
|
<Pressable onPress={onLike} hitSlop={12}>
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
<Svg width={16} height={16} 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.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
<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>
|
</Svg>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<DownloadButton
|
<TrackDownloadButton track={track} size={18} />
|
||||||
trackId={track.id}
|
|
||||||
isDownloaded={isDownloaded}
|
|
||||||
downloadProgress={downloadProgress}
|
|
||||||
onDownload={handleDownload}
|
|
||||||
onDelete={handleDownload}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
|
<Text style={styles.trackDur}>{fmt(track.duration)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|
@ -276,6 +246,8 @@ const styles = StyleSheet.create({
|
||||||
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||||
trackDur: { color: Colors.text3, fontSize: Font.xs, width: 36, textAlign: 'right' },
|
trackDur: { color: Colors.text3, fontSize: Font.xs, width: 36, textAlign: 'right' },
|
||||||
|
extBadge: { backgroundColor: 'rgba(255,255,255,0.07)', paddingHorizontal: 5, paddingVertical: 1, borderRadius: 4 },
|
||||||
|
extText: { color: Colors.text3, fontSize: 9, fontWeight: '700', letterSpacing: 0.5 },
|
||||||
// Now playing strip
|
// Now playing strip
|
||||||
npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 },
|
npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 },
|
||||||
npDim: { backgroundColor: 'rgba(0,0,0,0.5)' },
|
npDim: { backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,147 @@
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { View, Text, FlatList, StyleSheet, TextInput, Pressable } from 'react-native';
|
import {
|
||||||
|
View, Text, FlatList, StyleSheet, Pressable, Alert, TextInput, Modal,
|
||||||
|
ActionSheetIOS, Platform,
|
||||||
|
} from 'react-native';
|
||||||
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import Svg, { Circle, Line, Path } from 'react-native-svg';
|
import Svg, { Path, Circle, Line, Rect } from 'react-native-svg';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
|
||||||
import { getTracks, Track, coverUrl } from '../../services/api';
|
import { getTracks, Track, coverUrl } from '../../services/api';
|
||||||
import { usePlayer, useLikes } from '../../store';
|
import { usePlayer, useLikes, useHidden, usePlaylists, Playlist } from '../../store';
|
||||||
|
import { getMyWaveTracks } from '../../services/mywave';
|
||||||
|
import TrackDownloadButton from '../../components/DownloadButton';
|
||||||
|
import WaveAnimation from '../../components/WaveAnimation';
|
||||||
|
|
||||||
|
type Tab = 'tracks' | 'playlists' | 'wave';
|
||||||
|
|
||||||
export default function LibraryScreen() {
|
export default function LibraryScreen() {
|
||||||
const [all, setAll] = useState<Track[]>([]);
|
const [all, setAll] = useState<Track[]>([]);
|
||||||
const [filtered, setFiltered] = useState<Track[]>([]);
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [tab, setTab] = useState<Tab>('tracks');
|
||||||
|
const [wave, setWave] = useState<Track[]>([]);
|
||||||
|
const [waveLoading, setWaveLoading] = useState(false);
|
||||||
|
const [newPlName, setNewPlName] = useState('');
|
||||||
|
const [showNewPl, setShowNewPl] = useState(false);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { setQueue } = usePlayer();
|
const { setQueue } = usePlayer();
|
||||||
const { toggleLike, isLiked } = useLikes();
|
const { toggleLike, isLiked } = useLikes();
|
||||||
|
const { isHidden, toggleHide } = useHidden();
|
||||||
|
const { playlists, createPlaylist, deletePlaylist, renamePlaylist } = usePlaylists();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTracks().then(t => { setAll(t); setFiltered(t); });
|
getTracks().then(t => setAll(t));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const visible = all.filter(t => {
|
||||||
|
if (isHidden(t.id)) return false;
|
||||||
|
if (!query) return true;
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
setFiltered(q ? all.filter(t =>
|
return t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q);
|
||||||
t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q)
|
});
|
||||||
) : all);
|
|
||||||
}, [query, all]);
|
|
||||||
|
|
||||||
const playTrack = (idx: number) => {
|
const loadWave = useCallback(async () => {
|
||||||
|
if (wave.length > 0) { setTab('wave'); return; }
|
||||||
|
setWaveLoading(true);
|
||||||
|
const tracks = await getMyWaveTracks(all);
|
||||||
|
setWave(tracks);
|
||||||
|
setWaveLoading(false);
|
||||||
|
setTab('wave');
|
||||||
|
}, [all, wave.length]);
|
||||||
|
|
||||||
|
const playTrack = (list: Track[], idx: number) => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
setQueue(filtered, idx);
|
setQueue(list, idx);
|
||||||
usePlayer.getState().setExpanded(true);
|
usePlayer.getState().setExpanded(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLongPress = (track: Track) => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
const hidden = isHidden(track.id);
|
||||||
|
const options = [
|
||||||
|
hidden ? 'Показать трек' : 'Скрыть из библиотеки',
|
||||||
|
isLiked(track.id) ? 'Убрать из избранного' : 'В избранное',
|
||||||
|
'Отмена',
|
||||||
|
];
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
ActionSheetIOS.showActionSheetWithOptions(
|
||||||
|
{ options, cancelButtonIndex: 2, destructiveButtonIndex: 0 },
|
||||||
|
(i) => {
|
||||||
|
if (i === 0) toggleHide(track.id);
|
||||||
|
if (i === 1) toggleLike(track.id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert(track.title, undefined, [
|
||||||
|
{ text: hidden ? 'Показать' : 'Скрыть', onPress: () => toggleHide(track.id), style: 'destructive' },
|
||||||
|
{ text: isLiked(track.id) ? 'Убрать из избранного' : 'В избранное', onPress: () => toggleLike(track.id) },
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaylistLongPress = (pl: Playlist) => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
const options = ['Переименовать', 'Удалить', 'Отмена'];
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
ActionSheetIOS.showActionSheetWithOptions(
|
||||||
|
{ options, cancelButtonIndex: 2, destructiveButtonIndex: 1 },
|
||||||
|
(i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
Alert.prompt('Переименовать', undefined, (name) => {
|
||||||
|
if (name?.trim()) renamePlaylist(pl.id, name.trim());
|
||||||
|
}, undefined, pl.name);
|
||||||
|
}
|
||||||
|
if (i === 1) deletePlaylist(pl.id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert(pl.name, undefined, [
|
||||||
|
{ text: 'Удалить', onPress: () => deletePlaylist(pl.id), style: 'destructive' },
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
<View style={[s.root, { paddingTop: insets.top }]}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={s.header}>
|
||||||
<Text style={styles.title}>Библиотека</Text>
|
<Text style={s.title}>Библиотека</Text>
|
||||||
<Text style={styles.count}>{all.length} треков</Text>
|
<Pressable onPress={() => setShowNewPl(true)} style={s.addBtn} hitSlop={12}>
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M12 5v14M5 12h14" stroke={Colors.accent} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Tabs */}
|
||||||
<View style={styles.searchWrap}>
|
<View style={s.tabRow}>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none" style={{ marginLeft: 12 }}>
|
{(['tracks', 'playlists', 'wave'] as Tab[]).map(t => (
|
||||||
|
<Pressable
|
||||||
|
key={t}
|
||||||
|
style={[s.tabBtn, tab === t && s.tabBtnActive]}
|
||||||
|
onPress={() => t === 'wave' ? loadWave() : setTab(t)}
|
||||||
|
>
|
||||||
|
<Text style={[s.tabText, tab === t && s.tabTextActive]}>
|
||||||
|
{t === 'tracks' ? 'Треки' : t === 'playlists' ? 'Плейлисты' : 'Моя волна'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search (only on tracks tab) */}
|
||||||
|
{tab === 'tracks' && (
|
||||||
|
<View style={s.searchWrap}>
|
||||||
|
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none">
|
||||||
<Circle cx={11} cy={11} r={8} stroke={Colors.text3} strokeWidth={1.8} />
|
<Circle cx={11} cy={11} r={8} stroke={Colors.text3} strokeWidth={1.8} />
|
||||||
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" />
|
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.searchInput}
|
style={s.searchInput}
|
||||||
placeholder="Поиск треков..."
|
placeholder="Поиск..."
|
||||||
placeholderTextColor={Colors.text3}
|
placeholderTextColor={Colors.text3}
|
||||||
value={query}
|
value={query}
|
||||||
onChangeText={setQuery}
|
onChangeText={setQuery}
|
||||||
|
|
@ -59,82 +150,273 @@ export default function LibraryScreen() {
|
||||||
/>
|
/>
|
||||||
{!!query && (
|
{!!query && (
|
||||||
<Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}>
|
<Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}>
|
||||||
<Text style={{ color: Colors.text3, fontSize: 12 }}>✕</Text>
|
<Text style={{ color: Colors.text3, fontSize: 13 }}>x</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{tab === 'tracks' && (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={filtered}
|
data={visible}
|
||||||
keyExtractor={t => String(t.id)}
|
keyExtractor={t => String(t.id)}
|
||||||
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<TrackItem
|
<TrackRow
|
||||||
track={item}
|
track={item}
|
||||||
index={index}
|
index={index}
|
||||||
liked={isLiked(item.id)}
|
liked={isLiked(item.id)}
|
||||||
onPress={() => playTrack(index)}
|
onPress={() => playTrack(visible, index)}
|
||||||
onLike={() => toggleLike(item.id)}
|
onLike={() => toggleLike(item.id)}
|
||||||
|
onLongPress={() => handleLongPress(item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'playlists' && (
|
||||||
|
<FlatList
|
||||||
|
data={playlists}
|
||||||
|
keyExtractor={p => p.id}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScrollBeginDrag={() => Haptics.selectionAsync()}
|
||||||
|
ListEmptyComponent={<EmptyState text="Нет плейлистов. Нажмите + чтобы создать." />}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<PlaylistRow
|
||||||
|
playlist={item}
|
||||||
|
tracks={all}
|
||||||
|
onPress={() => {
|
||||||
|
const tracks = all.filter(t => item.trackIds.includes(t.id));
|
||||||
|
if (tracks.length) playTrack(tracks, 0);
|
||||||
|
}}
|
||||||
|
onLongPress={() => handlePlaylistLongPress(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'wave' && (
|
||||||
|
<FlatList
|
||||||
|
data={wave}
|
||||||
|
keyExtractor={t => String(t.id)}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={s.waveHeader}>
|
||||||
|
{/* Animated wave */}
|
||||||
|
<View style={s.waveViz}>
|
||||||
|
<WaveAnimation active={!waveLoading} barCount={50} height={64} />
|
||||||
|
</View>
|
||||||
|
<View style={s.waveGradientOverlay} pointerEvents="none" />
|
||||||
|
<Text style={s.waveTitle}>Моя волна</Text>
|
||||||
|
<Text style={s.waveSub}>
|
||||||
|
{waveLoading ? 'DeepSeek подбирает треки...' : `${wave.length} треков на основе ваших лайков`}
|
||||||
|
</Text>
|
||||||
|
{wave.length > 0 && (
|
||||||
|
<Pressable
|
||||||
|
style={s.playAllBtn}
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
playTrack(wave, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill={Colors.bg}>
|
||||||
|
<Path d="M8 5v14l11-7z" />
|
||||||
|
</Svg>
|
||||||
|
<Text style={s.playAllText}>Слушать волну</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
waveLoading
|
||||||
|
? <EmptyState text="Подбираем треки..." />
|
||||||
|
: <EmptyState text="Лайкните треки, чтобы получить рекомендации" />
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item}
|
||||||
|
index={index}
|
||||||
|
liked={isLiked(item.id)}
|
||||||
|
onPress={() => playTrack(wave, index)}
|
||||||
|
onLike={() => toggleLike(item.id)}
|
||||||
|
onLongPress={() => handleLongPress(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New playlist modal */}
|
||||||
|
<Modal visible={showNewPl} transparent animationType="slide">
|
||||||
|
<Pressable style={s.modalBg} onPress={() => setShowNewPl(false)} />
|
||||||
|
<View style={s.modalSheet}>
|
||||||
|
<Text style={s.modalTitle}>Новый плейлист</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.modalInput}
|
||||||
|
placeholder="Название..."
|
||||||
|
placeholderTextColor={Colors.text3}
|
||||||
|
value={newPlName}
|
||||||
|
onChangeText={setNewPlName}
|
||||||
|
autoFocus
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
<View style={s.modalBtns}>
|
||||||
|
<Pressable style={s.modalCancel} onPress={() => { setShowNewPl(false); setNewPlName(''); }}>
|
||||||
|
<Text style={s.modalCancelText}>Отмена</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[s.modalCreate, !newPlName.trim() && { opacity: 0.4 }]}
|
||||||
|
onPress={() => {
|
||||||
|
if (!newPlName.trim()) return;
|
||||||
|
createPlaylist(newPlName.trim());
|
||||||
|
setNewPlName('');
|
||||||
|
setShowNewPl(false);
|
||||||
|
setTab('playlists');
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={s.modalCreateText}>Создать</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackItem({ track, index, liked, onPress, onLike }: {
|
function TrackRow({ track, index, liked, onPress, onLike, onLongPress }: {
|
||||||
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void;
|
track: Track; index: number; liked: boolean;
|
||||||
|
onPress: () => void; onLike: () => void; onLongPress: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { queue, index: qi } = usePlayer();
|
const { queue, index: qi } = usePlayer();
|
||||||
const active = queue[qi]?.id === track.id;
|
const active = queue[qi]?.id === track.id;
|
||||||
const cover = coverUrl(track.cover_url);
|
const cover = coverUrl(track.cover_url);
|
||||||
|
const ext = (track.filename?.split('.').pop() || 'MP3').toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInDown.delay(Math.min(index * 25, 300)).duration(300)}>
|
<Animated.View entering={FadeInDown.delay(Math.min(index * 20, 300)).duration(250)}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[styles.row, active && styles.rowActive]}
|
onLongPress={onLongPress}
|
||||||
|
delayLongPress={350}
|
||||||
|
style={[s.row, active && s.rowActive]}
|
||||||
>
|
>
|
||||||
<Text style={styles.num}>{index + 1}</Text>
|
|
||||||
<Image
|
<Image
|
||||||
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
||||||
style={styles.cover}
|
style={s.cover}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
transition={200}
|
transition={200}
|
||||||
/>
|
/>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
<Text style={[s.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
|
||||||
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 2 }}>
|
||||||
|
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
<View style={s.extBadge}><Text style={s.extText}>{ext}</Text></View>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
<TrackDownloadButton track={track} size={18} />
|
||||||
<Pressable onPress={onLike} hitSlop={12}>
|
<Pressable onPress={onLike} hitSlop={12}>
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
<Svg width={16} height={16} 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.text3} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
<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>
|
</Svg>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.dur}>{fmt(track.duration)}</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(s: number) {
|
function PlaylistRow({ playlist, tracks, onPress, onLongPress }: {
|
||||||
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
playlist: Playlist; tracks: Track[]; onPress: () => void; onLongPress: () => void;
|
||||||
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
}) {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const aStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
|
const count = playlist.trackIds.length;
|
||||||
|
const cover = tracks.find(t => playlist.trackIds.includes(t.id))?.cover_url;
|
||||||
|
const coverUri = cover ? coverUrl(cover) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={aStyle}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
delayLongPress={350}
|
||||||
|
onPressIn={() => { scale.value = withSpring(0.97, { damping: 15 }); }}
|
||||||
|
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||||
|
style={s.plRow}
|
||||||
|
>
|
||||||
|
<View style={s.plCover}>
|
||||||
|
{coverUri
|
||||||
|
? <Image source={{ uri: coverUri }} style={{ width: 52, height: 52, borderRadius: Radius.sm }} contentFit="cover" />
|
||||||
|
: <View style={[s.plCoverFallback]}>
|
||||||
|
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M9 18V5l12-2v13" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
|
<Circle cx={6} cy={18} r={3} stroke={Colors.text3} strokeWidth={1.8} />
|
||||||
|
<Circle cx={18} cy={16} r={3} stroke={Colors.text3} strokeWidth={1.8} />
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={s.plName} numberOfLines={1}>{playlist.name}</Text>
|
||||||
|
<Text style={s.plCount}>{count} {count === 1 ? 'трек' : count < 5 ? 'трека' : 'треков'}</Text>
|
||||||
|
</View>
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M9 18l6-6-6-6" stroke={Colors.text3} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function EmptyState({ text }: { text: string }) {
|
||||||
container: { flex: 1, backgroundColor: Colors.bg },
|
return (
|
||||||
header: { flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm },
|
<View style={{ alignItems: 'center', marginTop: 80, paddingHorizontal: 32 }}>
|
||||||
|
<Text style={{ color: Colors.text3, fontSize: Font.sm, textAlign: 'center', lineHeight: 22 }}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: Colors.bg },
|
||||||
|
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: 8 },
|
||||||
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 },
|
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 },
|
||||||
count: { color: Colors.text3, fontSize: Font.sm },
|
addBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: Colors.surface, alignItems: 'center', justifyContent: 'center' },
|
||||||
searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: 8 },
|
tabRow: { flexDirection: 'row', paddingHorizontal: Spacing.md, gap: 8, marginBottom: 12 },
|
||||||
searchInput:{ flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11, paddingRight: Spacing.md },
|
tabBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: Radius.full, backgroundColor: Colors.surface },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: Spacing.sm, borderRadius: Radius.sm, paddingHorizontal: 4 },
|
tabBtnActive: { backgroundColor: Colors.accent },
|
||||||
|
tabText: { color: Colors.text2, fontSize: Font.sm, fontWeight: '600' },
|
||||||
|
tabTextActive:{ color: '#fff', fontSize: Font.sm, fontWeight: '700' },
|
||||||
|
searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: 8, paddingLeft: 12 },
|
||||||
|
searchInput: { flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 10, borderRadius: Radius.sm, paddingHorizontal: 4 },
|
||||||
rowActive: { backgroundColor: Colors.surface },
|
rowActive: { backgroundColor: Colors.surface },
|
||||||
num: { width: 22, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
cover: { width: 46, height: 46, borderRadius: Radius.sm, backgroundColor: Colors.elevated, flexShrink: 0 },
|
||||||
cover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
|
||||||
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
artist: { color: Colors.text2, fontSize: Font.sm, flex: 1 },
|
||||||
dur: { color: Colors.text3, fontSize: Font.xs },
|
extBadge: { backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 5, paddingVertical: 1, borderRadius: 4 },
|
||||||
|
extText: { color: Colors.text3, fontSize: 9, fontWeight: '700', letterSpacing: 0.5 },
|
||||||
|
plRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: 14, paddingHorizontal: 4 },
|
||||||
|
plCover: { width: 52, height: 52, borderRadius: Radius.sm, overflow: 'hidden', flexShrink: 0 },
|
||||||
|
plCoverFallback: { width: 52, height: 52, backgroundColor: Colors.elevated, borderRadius: Radius.sm, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
plName: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
|
plCount: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
|
||||||
|
waveHeader: { paddingBottom: 20, alignItems: 'center', overflow: 'hidden', borderRadius: 20, marginBottom: 8, backgroundColor: Colors.surface },
|
||||||
|
waveViz: { width: '100%', height: 80, overflow: 'hidden', marginBottom: 0 },
|
||||||
|
waveGradientOverlay: { position: 'absolute', top: 0, left: 0, right: 0, height: 80, backgroundColor: 'transparent' },
|
||||||
|
waveTitle: { color: Colors.text, fontSize: Font.xl, fontWeight: '800', letterSpacing: -0.3, marginTop: 16 },
|
||||||
|
waveSub: { color: Colors.text2, fontSize: Font.sm, marginTop: 4, marginBottom: 16 },
|
||||||
|
playAllBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: Colors.accent, paddingHorizontal: 28, paddingVertical: 13, borderRadius: Radius.full },
|
||||||
|
playAllText: { color: Colors.bg, fontSize: Font.md, fontWeight: '700' },
|
||||||
|
modalBg: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||||
|
modalSheet: { backgroundColor: Colors.surface, borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 24, paddingBottom: 40 },
|
||||||
|
modalTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: 16 },
|
||||||
|
modalInput: { backgroundColor: Colors.elevated, color: Colors.text, borderRadius: Radius.md, paddingHorizontal: 14, paddingVertical: 12, fontSize: Font.md, marginBottom: 16 },
|
||||||
|
modalBtns: { flexDirection: 'row', gap: 12 },
|
||||||
|
modalCancel: { flex: 1, paddingVertical: 14, borderRadius: Radius.md, backgroundColor: Colors.elevated, alignItems: 'center' },
|
||||||
|
modalCancelText: { color: Colors.text2, fontWeight: '600' },
|
||||||
|
modalCreate: { flex: 1, paddingVertical: 14, borderRadius: Radius.md, backgroundColor: Colors.accent, alignItems: 'center' },
|
||||||
|
modalCreateText: { color: '#fff', fontWeight: '700' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,133 @@
|
||||||
import { View, StyleSheet, Pressable, Text, ActivityIndicator } from 'react-native';
|
import { useState, useCallback } from 'react';
|
||||||
import Animated, { FadeIn, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
import { View, Text, StyleSheet, Pressable, Alert } from 'react-native';
|
||||||
import { useMemo } from 'react';
|
import Animated, {
|
||||||
import Svg, { Path } from 'react-native-svg';
|
useSharedValue, useAnimatedStyle, withTiming, withSpring,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Colors, Font } from '../constants/theme';
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import { Colors, Radius } from '../constants/theme';
|
||||||
|
import { useOffline } from '../store';
|
||||||
|
import { Track, coverUrl } from '../services/api';
|
||||||
|
|
||||||
export interface DownloadButtonProps {
|
interface Props {
|
||||||
trackId: number;
|
track: Track;
|
||||||
isDownloaded?: boolean;
|
size?: number;
|
||||||
downloadProgress?: number; // 0-1
|
showLabel?: boolean;
|
||||||
onDownload?: () => void;
|
|
||||||
onDelete?: () => void;
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DownloadButton({
|
const DL_DIR = FileSystem.documentDirectory + 'tracks/';
|
||||||
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 () => {
|
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);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
if (isDownloaded) {
|
scale.value = withSpring(0.85, { damping: 8 }, () => { scale.value = withSpring(1); });
|
||||||
onDelete?.();
|
|
||||||
} else {
|
try {
|
||||||
onDownload?.();
|
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 isLoading = downloadProgress > 0 && downloadProgress < 1;
|
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)';
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeIn}>
|
<Animated.View style={scaleStyle}>
|
||||||
<View style={[styles.container, { width: sizing, height: sizing }]}>
|
<Pressable onPress={startDownload} style={s.btn} hitSlop={12}>
|
||||||
<ActivityIndicator color={Colors.accent} size={sizing * 0.6} />
|
{isDownloading ? (
|
||||||
{downloadProgress > 0.2 && (
|
<View style={s.progressWrap}>
|
||||||
<Text style={[styles.progress, { fontSize: sizing * 0.3 }]}>
|
<View style={s.progressBg}>
|
||||||
{Math.round(downloadProgress * 100)}%
|
<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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const s = StyleSheet.create({
|
||||||
<Pressable
|
btn: { alignItems: 'center', justifyContent: 'center' },
|
||||||
onPress={handlePress}
|
progressWrap: { alignItems: 'center', gap: 4 },
|
||||||
style={({ pressed }) => [
|
progressBg: { width: 48, height: 3, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.12)', overflow: 'hidden' },
|
||||||
styles.button,
|
progressFill: { height: 3, backgroundColor: Colors.accent, borderRadius: 2 },
|
||||||
{
|
pctText: { color: Colors.accent, fontSize: 9, fontWeight: '700' },
|
||||||
width: sizing,
|
label: { fontSize: 12, fontWeight: '600' },
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue, useAnimatedStyle, withSpring,
|
useSharedValue, useAnimatedStyle, withSpring, withTiming,
|
||||||
FadeInDown, FadeOutDown, useAnimatedGestureHandler, runOnJS,
|
useAnimatedGestureHandler, runOnJS, interpolate, Extrapolate, Easing,
|
||||||
|
cancelAnimation,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { PanGestureHandler } from 'react-native-gesture-handler';
|
import { PanGestureHandler } from 'react-native-gesture-handler';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path, Rect } from 'react-native-svg';
|
||||||
import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme';
|
import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme';
|
||||||
import { usePlayer, useLikes } from '../store';
|
import { usePlayer, useLikes } from '../store';
|
||||||
import { togglePlay, seekTo } from '../hooks/useAudio';
|
import { togglePlay } from '../hooks/useAudio';
|
||||||
import { coverUrl } from '../services/api';
|
import { coverUrl } from '../services/api';
|
||||||
|
import SpectrumBars from './SpectrumBars';
|
||||||
|
|
||||||
export default function MiniPlayer() {
|
export default function MiniPlayer() {
|
||||||
const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer();
|
const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer();
|
||||||
|
|
@ -22,16 +24,41 @@ export default function MiniPlayer() {
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
const gestureHandler = useAnimatedGestureHandler({
|
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) => {
|
onActive: (e) => {
|
||||||
if (e.translationY < 0) {
|
if (e.translationY > 0) return; // block downward
|
||||||
translateY.value = Math.max(e.translationY, -80);
|
const dy = Math.max(e.translationY, -100);
|
||||||
opacity.value = 1 + e.translationY / 80;
|
translateY.value = dy;
|
||||||
}
|
opacity.value = interpolate(dy, [-100, 0], [0.6, 1], Extrapolate.CLAMP);
|
||||||
},
|
},
|
||||||
onEnd: (e) => {
|
onEnd: (e) => {
|
||||||
if (e.translationY < -40 || e.velocityY < -400) runOnJS(openPlayer)();
|
const shouldOpen = e.translationY < -40 || e.velocityY < -600;
|
||||||
translateY.value = withSpring(0);
|
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);
|
opacity.value = withSpring(1);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -41,45 +68,34 @@ export default function MiniPlayer() {
|
||||||
opacity: opacity.value,
|
opacity: opacity.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const openPlayer = useCallback(() => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
setExpanded(true);
|
|
||||||
}, [setExpanded]);
|
|
||||||
|
|
||||||
const onNext = useCallback(() => {
|
const onNext = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
const ni = usePlayer.getState().nextTrack();
|
const ni = usePlayer.getState().nextTrack();
|
||||||
usePlayer.getState().setIndex(ni);
|
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;
|
if (!track || expanded) return null;
|
||||||
|
|
||||||
const cover = coverUrl(track.cover_url);
|
const cover = coverUrl(track.cover_url);
|
||||||
const liked = isLiked(track.id);
|
|
||||||
const pct = duration > 0 ? (progress / duration) * 100 : 0;
|
const pct = duration > 0 ? (progress / duration) * 100 : 0;
|
||||||
|
const liked = isLiked(track.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanGestureHandler onGestureEvent={gestureHandler}>
|
<PanGestureHandler
|
||||||
<Animated.View
|
onGestureEvent={gestureHandler}
|
||||||
style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
|
activeOffsetY={[-8, 8]}
|
||||||
entering={FadeInDown.springify()}
|
failOffsetX={[-20, 20]}
|
||||||
exiting={FadeOutDown.springify()}
|
|
||||||
>
|
>
|
||||||
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
|
<Animated.View style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}>
|
||||||
|
<BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
<View style={[StyleSheet.absoluteFill, s.bgTint]} />
|
<View style={[StyleSheet.absoluteFill, s.bgTint]} />
|
||||||
|
|
||||||
{/* Progress line */}
|
{/* Progress bar */}
|
||||||
<View style={s.progressBg}>
|
<View style={s.progressBg}>
|
||||||
<View style={[s.progressFill, { width: `${pct}%` as any }]} />
|
<View style={[s.progressFill, { width: `${pct}%` as any }]} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable style={s.row} onPress={openPlayer} android_ripple={null}>
|
<Pressable style={s.row} onPress={openPlayer}>
|
||||||
<Image
|
<Image
|
||||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
style={s.cover}
|
style={s.cover}
|
||||||
|
|
@ -88,24 +104,41 @@ export default function MiniPlayer() {
|
||||||
/>
|
/>
|
||||||
<View style={s.info}>
|
<View style={s.info}>
|
||||||
<Text style={s.title} numberOfLines={1}>{track.title}</Text>
|
<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>
|
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
<View style={s.controls}>
|
<View style={s.controls}>
|
||||||
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}>
|
<Pressable
|
||||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }}
|
||||||
<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" />
|
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>
|
</Svg>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={(e) => { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}>
|
<Pressable
|
||||||
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></Svg>
|
onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }}
|
||||||
</Pressable>
|
hitSlop={12} style={s.ctlBtn}
|
||||||
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}>
|
>
|
||||||
{playing
|
{playing
|
||||||
? <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg>
|
? <Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.text}>
|
||||||
: <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>}
|
<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>
|
||||||
<Pressable onPress={(e) => { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}>
|
<Pressable
|
||||||
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg>
|
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>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
@ -115,15 +148,15 @@ export default function MiniPlayer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderTopLeftRadius: 14, borderTopRightRadius: 14 },
|
wrap: { position: 'absolute', left: 8, right: 8, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderRadius: 16 },
|
||||||
bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' },
|
bgTint: { backgroundColor: 'rgba(18,18,20,0.5)' },
|
||||||
progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 },
|
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 },
|
progressFill: { height: 2, backgroundColor: Colors.accent },
|
||||||
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 },
|
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 12, paddingRight: 2, gap: 10 },
|
||||||
cover: { width: 44, height: 44, borderRadius: 9, backgroundColor: Colors.elevated, flexShrink: 0 },
|
cover: { width: 46, height: 46, borderRadius: 10, backgroundColor: Colors.elevated },
|
||||||
info: { flex: 1, minWidth: 0 },
|
info: { flex: 1, minWidth: 0 },
|
||||||
title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
artist: { color: 'rgba(255,255,255,0.5)', fontSize: Font.xs, marginTop: 2 },
|
||||||
controls: { flexDirection: 'row', alignItems: 'center' },
|
controls: { flexDirection: 'row', alignItems: 'center' },
|
||||||
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
|
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView, ViewStyle } from 'react-native';
|
import {
|
||||||
|
View, Text, StyleSheet, Pressable, Dimensions,
|
||||||
|
ScrollView, Platform, StatusBar, Alert,
|
||||||
|
} from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue, useAnimatedStyle, withSpring, withDecay,
|
useSharedValue, useAnimatedStyle, withSpring, withTiming,
|
||||||
useAnimatedGestureHandler, runOnJS, FadeIn, interpolate, Easing, withTiming,
|
useAnimatedGestureHandler, runOnJS, interpolate, Extrapolate,
|
||||||
|
Easing, cancelAnimation,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { PanGestureHandler } from 'react-native-gesture-handler';
|
import { PanGestureHandler } from 'react-native-gesture-handler';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
|
|
@ -10,16 +14,20 @@ import Slider from '@react-native-community/slider';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import Svg, { Path, Polyline, Line, Rect, Circle } from 'react-native-svg';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import Svg, { Path, Rect, Polyline, Circle } from 'react-native-svg';
|
||||||
import { Colors, Font, Radius } from '../constants/theme';
|
import { Colors, Font, Radius } from '../constants/theme';
|
||||||
import { usePlayer, useLikes } from '../store';
|
import { usePlayer, useLikes } from '../store';
|
||||||
import { togglePlay, seekTo } from '../hooks/useAudio';
|
import { togglePlay, seekTo } from '../hooks/useAudio';
|
||||||
import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api';
|
import { coverUrl, fetchLyrics, parseLrc, LrcLine } from '../services/api';
|
||||||
|
import { useDynamicColor, useNextTrackPreload } from '../hooks/usePlayerFeatures';
|
||||||
|
import { useShakeToShuffle } from '../hooks/useShakeToShuffle';
|
||||||
|
import ShareTrackModal from './ShareTrackModal';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
const COVER = width - 48;
|
const COVER_SIZE = width - 56;
|
||||||
const MIN_SWIPE_DISTANCE = 50;
|
const DISMISS_THRESHOLD = 120;
|
||||||
const MAX_SWIPE_DISTANCE = height * 0.4;
|
const DISMISS_VELOCITY = 800;
|
||||||
|
|
||||||
export default function Player() {
|
export default function Player() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -30,114 +38,181 @@ export default function Player() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const track = queue[index];
|
const track = queue[index];
|
||||||
|
|
||||||
const [lyricsOpen, setLyricsOpen] = useState(false);
|
const [lyricsMode, setLyricsMode] = useState(false);
|
||||||
const [lrcLines, setLrcLines] = useState<LrcLine[]>([]);
|
const [lrcLines, setLrcLines] = useState<LrcLine[]>([]);
|
||||||
const [plainLyrics, setPlain] = useState<string | null>(null);
|
const [plainLyrics, setPlain] = useState<string | null>(null);
|
||||||
const [lyricsLoading, setLyricsLoading] = useState(false);
|
const [lyricsLoading, setLyricsLoading] = useState(false);
|
||||||
|
const [isSeeking, setIsSeeking] = useState(false);
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
const lyricsScrollRef = useRef<ScrollView>(null);
|
const lyricsScrollRef = useRef<ScrollView>(null);
|
||||||
const lineHeights = useRef<number[]>([]);
|
const lineHeights = useRef<number[]>([]);
|
||||||
const gestureRef = useRef<any>(null);
|
const panRef = useRef<any>(null);
|
||||||
|
const coverPanRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Shared values
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
const coverScale = useSharedValue(1);
|
const coverScale = useSharedValue(playing ? 1 : 0.875);
|
||||||
const coverRotate = useSharedValue(0);
|
const coverTranslateX = useSharedValue(0);
|
||||||
|
const lyricsOpacity = useSharedValue(0);
|
||||||
|
const playerOpacity = useSharedValue(1);
|
||||||
|
|
||||||
|
// Dynamic color from cover
|
||||||
|
const cover = track ? coverUrl(track.cover_url) : null;
|
||||||
|
const dynColor = useDynamicColor(cover);
|
||||||
|
|
||||||
|
// Shake → shuffle
|
||||||
|
const onShake = useCallback(() => {
|
||||||
|
if (!shuffle) toggleShuffle();
|
||||||
|
const ni = usePlayer.getState().nextTrack();
|
||||||
|
usePlayer.getState().setIndex(ni);
|
||||||
|
}, [shuffle, toggleShuffle]);
|
||||||
|
useShakeToShuffle(onShake);
|
||||||
|
|
||||||
|
// Preload next track 30s before end
|
||||||
|
useNextTrackPreload();
|
||||||
|
|
||||||
|
// Cover scale on play/pause
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 14, mass: 1, stiffness: 100 });
|
coverScale.value = withSpring(playing ? 1 : 0.875, {
|
||||||
|
damping: 18, mass: 1, stiffness: 120,
|
||||||
|
});
|
||||||
}, [playing]);
|
}, [playing]);
|
||||||
|
|
||||||
|
// Lyrics mode transition
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLrcLines([]); setPlain(null); setLyricsOpen(false);
|
if (lyricsMode) {
|
||||||
|
lyricsOpacity.value = withTiming(1, { duration: 350, easing: Easing.out(Easing.cubic) });
|
||||||
|
playerOpacity.value = withTiming(0, { duration: 250 });
|
||||||
|
} else {
|
||||||
|
lyricsOpacity.value = withTiming(0, { duration: 250 });
|
||||||
|
playerOpacity.value = withTiming(1, { duration: 350, easing: Easing.out(Easing.cubic) });
|
||||||
|
}
|
||||||
|
}, [lyricsMode]);
|
||||||
|
|
||||||
|
// Reset on track change
|
||||||
|
useEffect(() => {
|
||||||
|
setLrcLines([]); setPlain(null); setLyricsMode(false);
|
||||||
translateY.value = 0;
|
translateY.value = 0;
|
||||||
|
coverScale.value = withSpring(1, { damping: 18, stiffness: 120 });
|
||||||
}, [track?.id]);
|
}, [track?.id]);
|
||||||
|
|
||||||
const loadLyrics = useCallback(async () => {
|
// Auto-scroll lyrics
|
||||||
if (!track) return;
|
|
||||||
if (lrcLines.length > 0 || plainLyrics) { setLyricsOpen(true); return; }
|
|
||||||
setLyricsLoading(true);
|
|
||||||
const data = await fetchLyrics(track.id);
|
|
||||||
setLyricsLoading(false);
|
|
||||||
if (data?.synced) setLrcLines(parseLrc(data.synced));
|
|
||||||
else if (data?.plain) setPlain(data.plain);
|
|
||||||
setLyricsOpen(true);
|
|
||||||
}, [track, lrcLines.length, plainLyrics]);
|
|
||||||
|
|
||||||
const currentLineIdx = lrcLines.length > 0
|
const currentLineIdx = lrcLines.length > 0
|
||||||
? lrcLines.reduce((acc, line, i) => line.time <= progress ? i : acc, 0)
|
? lrcLines.reduce((acc, line, i) => line.time <= progress ? i : acc, 0)
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lyricsOpen || currentLineIdx < 0) return;
|
if (!lyricsMode || currentLineIdx < 1) return;
|
||||||
const y = lineHeights.current.slice(0, currentLineIdx).reduce((sum, h) => sum + h, 0);
|
const y = lineHeights.current.slice(0, currentLineIdx - 1).reduce((s, h) => s + h, 0);
|
||||||
lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 100), animated: true });
|
lyricsScrollRef.current?.scrollTo({ y, animated: true });
|
||||||
}, [currentLineIdx, lyricsOpen]);
|
}, [currentLineIdx, lyricsMode]);
|
||||||
|
|
||||||
const gestureHandler = useAnimatedGestureHandler<any, { startY: number, startX: number }>({
|
const loadLyrics = useCallback(async () => {
|
||||||
onStart: (_, ctx) => {
|
if (!track) return;
|
||||||
ctx.startY = translateY.value;
|
if (lrcLines.length > 0 || plainLyrics) { setLyricsMode(true); return; }
|
||||||
ctx.startX = 0;
|
setLyricsLoading(true);
|
||||||
},
|
|
||||||
onActive: (e, ctx) => {
|
|
||||||
try {
|
try {
|
||||||
// Only allow vertical swipe, prevent diagonal
|
const data = await fetchLyrics(track.id);
|
||||||
if (Math.abs(e.translationX) > Math.abs(e.translationY) * 0.5) return;
|
if (data?.synced) setLrcLines(parseLrc(data.synced));
|
||||||
|
else if (data?.plain) setPlain(data.plain);
|
||||||
if (e.translationY > 0) {
|
} catch {}
|
||||||
const clamped = Math.min(e.translationY, MAX_SWIPE_DISTANCE);
|
setLyricsLoading(false);
|
||||||
translateY.value = ctx.startY + clamped;
|
setLyricsMode(true);
|
||||||
coverRotate.value = withTiming(clamped / 100, { duration: 50, easing: Easing.linear });
|
}, [track, lrcLines.length, plainLyrics]);
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Gesture error:', err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onEnd: (e) => {
|
|
||||||
try {
|
|
||||||
const shouldClose = e.translationY > MIN_SWIPE_DISTANCE || e.velocityY > 500;
|
|
||||||
|
|
||||||
if (shouldClose) {
|
|
||||||
translateY.value = withTiming(height, {
|
|
||||||
duration: 300,
|
|
||||||
easing: Easing.out(Easing.ease)
|
|
||||||
}, () => {
|
|
||||||
runOnJS(close)();
|
|
||||||
});
|
|
||||||
coverRotate.value = withTiming(0, { duration: 300 });
|
|
||||||
} else {
|
|
||||||
translateY.value = withSpring(0, {
|
|
||||||
damping: 15,
|
|
||||||
mass: 1,
|
|
||||||
stiffness: 120
|
|
||||||
});
|
|
||||||
coverRotate.value = withTiming(0, { duration: 200 });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Gesture end error:', err);
|
|
||||||
translateY.value = withSpring(0, { damping: 15, mass: 1 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const containerStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ translateY: translateY.value }]
|
|
||||||
}));
|
|
||||||
|
|
||||||
const overlayOpacity = useAnimatedStyle(() => ({
|
|
||||||
opacity: interpolate(translateY.value, [0, MAX_SWIPE_DISTANCE], [1, 0.2])
|
|
||||||
}));
|
|
||||||
|
|
||||||
const coverStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [
|
|
||||||
{ scale: coverScale.value },
|
|
||||||
{ rotateZ: `${coverRotate.value}deg` }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
|
translateY.value = 0;
|
||||||
}, [setExpanded]);
|
}, [setExpanded]);
|
||||||
|
|
||||||
|
// Gesture handler - fixed to prevent crashes
|
||||||
|
const gestureHandler = useAnimatedGestureHandler<any, { start: number }>({
|
||||||
|
onStart: (_, ctx) => {
|
||||||
|
cancelAnimation(translateY);
|
||||||
|
ctx.start = translateY.value;
|
||||||
|
},
|
||||||
|
onActive: (e, ctx) => {
|
||||||
|
const dy = e.translationY;
|
||||||
|
if (dy <= 0) return;
|
||||||
|
// Rubber band resistance
|
||||||
|
const resistance = 1 - Math.min(dy / (height * 0.9), 0.85);
|
||||||
|
translateY.value = ctx.start + dy * resistance;
|
||||||
|
},
|
||||||
|
onEnd: (e) => {
|
||||||
|
const shouldDismiss =
|
||||||
|
e.translationY > DISMISS_THRESHOLD || e.velocityY > DISMISS_VELOCITY;
|
||||||
|
if (shouldDismiss) {
|
||||||
|
translateY.value = withTiming(
|
||||||
|
height,
|
||||||
|
{ duration: 380, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94) },
|
||||||
|
(done) => { if (done) runOnJS(close)(); }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
translateY.value = withSpring(0, {
|
||||||
|
damping: 26, mass: 1, stiffness: 200, overshootClamping: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFail: () => {
|
||||||
|
translateY.value = withSpring(0, { damping: 26, stiffness: 200 });
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
translateY.value = withSpring(0, { damping: 26, stiffness: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const overlayStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: interpolate(translateY.value, [0, height * 0.5], [1, 0], Extrapolate.CLAMP),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const coverStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: coverScale.value }, { translateX: coverTranslateX.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lyricsStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: lyricsOpacity.value,
|
||||||
|
pointerEvents: lyricsOpacity.value > 0.5 ? 'auto' : 'none',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const playerControlsStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: playerOpacity.value,
|
||||||
|
pointerEvents: playerOpacity.value > 0.5 ? 'auto' : 'none',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Horizontal swipe on cover → next/prev track
|
||||||
|
const coverGesture = useAnimatedGestureHandler<any, { startX: number }>({
|
||||||
|
onStart: (_, ctx) => { ctx.startX = coverTranslateX.value; },
|
||||||
|
onActive: (e, ctx) => {
|
||||||
|
if (Math.abs(e.translationY) > Math.abs(e.translationX) * 1.5) return;
|
||||||
|
coverTranslateX.value = e.translationX * 0.4;
|
||||||
|
},
|
||||||
|
onEnd: (e) => {
|
||||||
|
const THRESH = 60;
|
||||||
|
if (e.translationX < -THRESH) {
|
||||||
|
coverTranslateX.value = withTiming(-width, { duration: 220 }, () => {
|
||||||
|
runOnJS(onNext)();
|
||||||
|
coverTranslateX.value = width;
|
||||||
|
coverTranslateX.value = withSpring(0, { damping: 20, stiffness: 180 });
|
||||||
|
});
|
||||||
|
} else if (e.translationX > THRESH) {
|
||||||
|
coverTranslateX.value = withTiming(width, { duration: 220 }, () => {
|
||||||
|
runOnJS(onPrev)();
|
||||||
|
coverTranslateX.value = -width;
|
||||||
|
coverTranslateX.value = withSpring(0, { damping: 20, stiffness: 180 });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
coverTranslateX.value = withSpring(0, { damping: 20, stiffness: 220 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFail: () => { coverTranslateX.value = withSpring(0, { damping: 20 }); },
|
||||||
|
onCancel: () => { coverTranslateX.value = withSpring(0, { damping: 20 }); },
|
||||||
|
});
|
||||||
|
|
||||||
const onNext = useCallback(() => {
|
const onNext = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
const ni = usePlayer.getState().nextTrack();
|
const ni = usePlayer.getState().nextTrack();
|
||||||
|
|
@ -151,107 +226,221 @@ export default function Player() {
|
||||||
usePlayer.getState().setIndex(pi);
|
usePlayer.getState().setIndex(pi);
|
||||||
}, [progress]);
|
}, [progress]);
|
||||||
|
|
||||||
|
const onCopyTitle = useCallback(() => {
|
||||||
|
Clipboard.setStringAsync(`${track?.artist} — ${track?.title}`);
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
}, [track]);
|
||||||
|
|
||||||
if (!track) return null;
|
if (!track) return null;
|
||||||
|
|
||||||
const cover = coverUrl(track.cover_url);
|
// cover is already declared above via dynColor
|
||||||
const pct = duration > 0 ? progress / duration : 0;
|
const pct = duration > 0 ? progress / duration : 0;
|
||||||
const liked = isLiked(track.id);
|
const liked = isLiked(track.id);
|
||||||
|
const ext = (track.filename?.split('.').pop() || 'MP3').toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}>
|
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayStyle]}>
|
||||||
{/* Blurred background with better gradient */}
|
<StatusBar barStyle="light-content" />
|
||||||
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={100} />
|
|
||||||
|
{/* Blurred background */}
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
contentFit="cover"
|
||||||
|
blurRadius={80}
|
||||||
|
/>
|
||||||
|
{/* Dynamic color background overlay */}
|
||||||
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
|
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
|
||||||
<BlurView intensity={25} tint="dark" style={StyleSheet.absoluteFill} />
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: dynColor, opacity: 0.35 }]} />
|
||||||
|
<BlurView intensity={20} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
|
||||||
<PanGestureHandler ref={gestureRef} onGestureEvent={gestureHandler}>
|
<PanGestureHandler
|
||||||
<Animated.View style={[s.sheet, containerStyle, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 16 }]}>
|
ref={panRef}
|
||||||
|
onGestureEvent={gestureHandler}
|
||||||
|
activeOffsetY={[0, 15]}
|
||||||
|
failOffsetY={[-15, 0]}
|
||||||
|
failOffsetX={[-20, 20]}
|
||||||
|
>
|
||||||
|
<Animated.View style={[s.sheet, containerStyle, {
|
||||||
|
paddingTop: insets.top + 12,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 20) + 8,
|
||||||
|
}]}>
|
||||||
|
|
||||||
{/* Top bar with better design */}
|
{/* Handle bar */}
|
||||||
<View style={s.topBar}>
|
<View style={s.handleBar}>
|
||||||
<Text style={s.topLabel}>Сейчас играет</Text>
|
<View style={s.handle} />
|
||||||
<Pressable onPress={close} hitSlop={20} style={s.closeBtn}>
|
</View>
|
||||||
<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" />
|
{/* Header */}
|
||||||
|
<View style={s.header}>
|
||||||
|
<Pressable onPress={close} hitSlop={20} style={s.headerBtn}>
|
||||||
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M19 12H5M12 19l-7-7 7-7" stroke="rgba(255,255,255,0.7)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ alignItems: 'center' }}>
|
||||||
|
<Text style={s.headerLabel}>Воспроизведение</Text>
|
||||||
|
<Text style={s.headerSub} numberOfLines={1}>{track.album || 'Треки'}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable hitSlop={20} style={s.headerBtn}>
|
||||||
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Circle cx={12} cy={5} r={1.5} fill="rgba(255,255,255,0.7)" />
|
||||||
|
<Circle cx={12} cy={12} r={1.5} fill="rgba(255,255,255,0.7)" />
|
||||||
|
<Circle cx={12} cy={19} r={1.5} fill="rgba(255,255,255,0.7)" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Cover with better shadow */}
|
{/* Cover / Lyrics toggle area */}
|
||||||
<View style={s.coverWrap}>
|
<View style={s.mainArea}>
|
||||||
|
{/* Cover with horizontal swipe */}
|
||||||
|
<Animated.View style={[s.coverWrap, playerControlsStyle]}>
|
||||||
|
<PanGestureHandler
|
||||||
|
ref={coverPanRef}
|
||||||
|
onGestureEvent={coverGesture}
|
||||||
|
activeOffsetX={[-12, 12]}
|
||||||
|
failOffsetY={[-20, 20]}
|
||||||
|
>
|
||||||
<Animated.View style={[s.coverShadow, coverStyle]}>
|
<Animated.View style={[s.coverShadow, coverStyle]}>
|
||||||
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={s.cover} contentFit="cover" transition={300} />
|
<Pressable onPress={() => setLyricsMode(true)} onLongPress={onCopyTitle} delayLongPress={600}>
|
||||||
|
<Image
|
||||||
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
|
style={s.cover}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
</PanGestureHandler>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Lyrics overlay */}
|
||||||
|
<Animated.View style={[StyleSheet.absoluteFill, s.lyricsOverlay, lyricsStyle]}>
|
||||||
|
<Pressable style={s.lyricsCloseHint} onPress={() => setLyricsMode(false)}>
|
||||||
|
<Text style={s.lyricsCloseText}>Закрыть текст</Text>
|
||||||
|
</Pressable>
|
||||||
|
<ScrollView
|
||||||
|
ref={lyricsScrollRef}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={s.lyricsContent}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
>
|
||||||
|
{lrcLines.length > 0
|
||||||
|
? lrcLines.map((line, i) => (
|
||||||
|
<LyricLine
|
||||||
|
key={i}
|
||||||
|
text={line.text}
|
||||||
|
isActive={i === currentLineIdx}
|
||||||
|
isPast={i < currentLineIdx}
|
||||||
|
onLayout={(h) => { lineHeights.current[i] = h; }}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: plainLyrics
|
||||||
|
? <Text style={s.plainLyrics}>{plainLyrics}</Text>
|
||||||
|
: <Text style={s.lyrEmpty}>Текст недоступен</Text>
|
||||||
|
}
|
||||||
|
<View style={{ height: 100 }} />
|
||||||
|
</ScrollView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Meta with better spacing */}
|
{/* Meta + like + share */}
|
||||||
<View style={s.metaRow}>
|
<View style={s.meta}>
|
||||||
<View style={s.metaText}>
|
<View style={s.metaInfo}>
|
||||||
|
<Pressable onLongPress={onCopyTitle} delayLongPress={400}>
|
||||||
<Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text>
|
<Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text>
|
||||||
|
</Pressable>
|
||||||
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={16}>
|
<Pressable
|
||||||
<Svg width={24} height={24} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setShareOpen(true); }}
|
||||||
<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" />
|
hitSlop={16} style={s.likeBtn}
|
||||||
|
>
|
||||||
|
<Svg width={22} height={22} 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="rgba(255,255,255,0.6)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }}
|
||||||
|
hitSlop={16} style={s.likeBtn}
|
||||||
|
>
|
||||||
|
<Svg width={26} height={26} 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.6)'} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Seek with gradient track */}
|
{/* Format badge */}
|
||||||
|
<View style={s.formatRow}>
|
||||||
|
<View style={s.formatBadge}>
|
||||||
|
<Text style={s.formatText}>{ext}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Seeker */}
|
||||||
<View style={s.seekWrap}>
|
<View style={s.seekWrap}>
|
||||||
<View style={s.sliderContainer}>
|
|
||||||
<Slider
|
<Slider
|
||||||
style={{ flex: 1, height: 40 }}
|
style={s.slider}
|
||||||
minimumValue={0}
|
minimumValue={0}
|
||||||
maximumValue={1}
|
maximumValue={1}
|
||||||
value={pct}
|
value={isSeeking ? undefined : pct}
|
||||||
minimumTrackTintColor={Colors.accent}
|
minimumTrackTintColor={Colors.text}
|
||||||
maximumTrackTintColor="rgba(255,255,255,0.15)"
|
maximumTrackTintColor="rgba(255,255,255,0.15)"
|
||||||
thumbTintColor={Colors.text}
|
thumbTintColor={Colors.text}
|
||||||
onSlidingComplete={(v) => seekTo(v * duration)}
|
onSlidingStart={() => setIsSeeking(true)}
|
||||||
|
onSlidingComplete={(v) => { setIsSeeking(false); seekTo(v * duration); }}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
<View style={s.timeRow}>
|
<View style={s.timeRow}>
|
||||||
<Text style={s.timeText}>{fmt(progress)}</Text>
|
<Text style={s.timeText}>{fmt(progress)}</Text>
|
||||||
<Text style={s.timeText}>{fmt(duration - progress)}</Text>
|
<Text style={s.timeText}>-{fmt(duration - progress)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Controls with better styling */}
|
{/* Controls */}
|
||||||
<View style={s.controls}>
|
<View style={s.controls}>
|
||||||
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
|
<IconBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleShuffle(); }} active={shuffle}>
|
||||||
<ShuffleIcon active={shuffle} />
|
<ShuffleIcon active={shuffle} />
|
||||||
</CtlBtn>
|
</IconBtn>
|
||||||
<CtlBtn onPress={onPrev}>
|
<IconBtn onPress={onPrev} scale={0.85}>
|
||||||
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
|
<Svg width={32} height={32} viewBox="0 0 24 24" fill={Colors.text}>
|
||||||
<Rect x={5} y={6} width={2.5} height={16} rx={1.2} fill={Colors.text} />
|
<Path d="M19 20L9 12l10-8v16zM5 4h2v16H5z" />
|
||||||
<Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} />
|
|
||||||
</Svg>
|
</Svg>
|
||||||
</CtlBtn>
|
</IconBtn>
|
||||||
<Pressable style={s.playBtn} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}>
|
<Pressable
|
||||||
|
style={s.playBtn}
|
||||||
|
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}
|
||||||
|
>
|
||||||
{playing
|
{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={30} height={30} viewBox="0 0 24 24" fill={Colors.bg}>
|
||||||
: <Svg width={28} height={28} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" /></Svg>}
|
<Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} />
|
||||||
</Pressable>
|
<Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.bg} />
|
||||||
<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>
|
</Svg>
|
||||||
</CtlBtn>
|
: <Svg width={30} height={30} viewBox="0 0 24 24" fill={Colors.bg}>
|
||||||
<CtlBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
|
<Path d="M8 5v14l11-7z" />
|
||||||
|
</Svg>
|
||||||
|
}
|
||||||
|
</Pressable>
|
||||||
|
<IconBtn onPress={onNext} scale={0.85}>
|
||||||
|
<Svg width={32} height={32} viewBox="0 0 24 24" fill={Colors.text}>
|
||||||
|
<Path d="M5 4l10 8-10 8V4zM19 4h-2v16h2z" />
|
||||||
|
</Svg>
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); cycleRepeat(); }} active={repeat > 0}>
|
||||||
<RepeatIcon repeat={repeat} />
|
<RepeatIcon repeat={repeat} />
|
||||||
</CtlBtn>
|
</IconBtn>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Lyrics button */}
|
{/* Bottom row */}
|
||||||
<View style={s.bottomRow}>
|
<View style={s.bottomRow}>
|
||||||
<Pressable style={[s.lyrBtn, lyricsOpen && s.lyrBtnActive]} onPress={loadLyrics}>
|
<Pressable
|
||||||
<Svg width={15} height={15} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.5)'} strokeWidth={2} viewBox="0 0 24 24">
|
style={[s.lyrBtn, !!(lyricsMode || lrcLines.length > 0 || plainLyrics) && s.lyrBtnActive]}
|
||||||
<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" />
|
onPress={loadLyrics}
|
||||||
|
>
|
||||||
|
<Svg width={15} height={15} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path d="M3 8h18M3 12h18M3 16h12" stroke={lyricsMode ? Colors.accent : 'rgba(255,255,255,0.5)'} strokeWidth={2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text style={[s.lyrBtnText, lyricsOpen && { color: Colors.accent }]}>
|
<Text style={[s.lyrBtnText, lyricsMode && { color: Colors.accent }]}>
|
||||||
{lyricsLoading ? 'Загрузка…' : 'Текст'}
|
{lyricsLoading ? 'Загрузка...' : 'Текст песни'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -259,60 +448,58 @@ export default function Player() {
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</PanGestureHandler>
|
</PanGestureHandler>
|
||||||
|
|
||||||
{/* Lyrics sheet with better design */}
|
{/* Share modal */}
|
||||||
{lyricsOpen && (
|
<ShareTrackModal
|
||||||
<View style={s.lyrSheet}>
|
track={track}
|
||||||
<Pressable style={StyleSheet.absoluteFill} onPress={() => setLyricsOpen(false)} />
|
progress={progress}
|
||||||
<BlurView intensity={50} tint="dark" style={s.lyrInner}>
|
visible={shareOpen}
|
||||||
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
|
onClose={() => setShareOpen(false)}
|
||||||
<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>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CtlBtn({ onPress, active = false, children }: { onPress: () => void; active?: boolean; children: React.ReactNode }) {
|
// Animated lyric line - Apple Music style
|
||||||
|
function LyricLine({ text, isActive, isPast, onLayout }: {
|
||||||
|
text: string; isActive: boolean; isPast: boolean; onLayout: (h: number) => void;
|
||||||
|
}) {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const opacity = useSharedValue(isPast ? 0.4 : isActive ? 1 : 0.3);
|
||||||
|
const fontSize = useSharedValue(isActive ? 32 : 22);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.value = withTiming(isActive ? 1 : isPast ? 0.45 : 0.28, { duration: 350 });
|
||||||
|
scale.value = withSpring(isActive ? 1 : 0.92, { damping: 18, stiffness: 180 });
|
||||||
|
}, [isActive, isPast]);
|
||||||
|
|
||||||
|
const style = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.Text
|
||||||
|
style={[s.lyrLine, style, isActive && s.lyrLineActive, isPast && s.lyrLinePast]}
|
||||||
|
onLayout={(e) => onLayout(e.nativeEvent.layout.height)}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Animated.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconBtn({ onPress, active = false, scale = 0.8, children }: {
|
||||||
|
onPress: () => void; active?: boolean; scale?: number; children: React.ReactNode;
|
||||||
|
}) {
|
||||||
const sc = useSharedValue(1);
|
const sc = useSharedValue(1);
|
||||||
const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] }));
|
const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] }));
|
||||||
return (
|
return (
|
||||||
<Animated.View style={st}>
|
<Animated.View style={st}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onPressIn={() => { sc.value = withSpring(0.75, { damping: 10, mass: 0.8 }); }}
|
onPressIn={() => { sc.value = withSpring(scale, { damping: 10, mass: 0.6 }); }}
|
||||||
onPressOut={() => { sc.value = withSpring(1, { damping: 10, mass: 0.8 }); }}
|
onPressOut={() => { sc.value = withSpring(1, { damping: 10, mass: 0.6 }); }}
|
||||||
hitSlop={14}
|
hitSlop={16}
|
||||||
style={[s.ctlBtn, active && s.ctlBtnActive]}
|
style={[s.iconBtn, active && s.iconBtnActive]}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
@ -321,79 +508,94 @@ function CtlBtn({ onPress, active = false, children }: { onPress: () => void; ac
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShuffleIcon({ active }: { active: boolean }) {
|
function ShuffleIcon({ active }: { active: boolean }) {
|
||||||
const c = active ? Colors.accent : 'rgba(255,255,255,0.6)';
|
const c = active ? Colors.accent : 'rgba(255,255,255,0.65)';
|
||||||
return (
|
return (
|
||||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
<Polyline points="16,3 21,3 21,8" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
<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.2} strokeLinecap="round" strokeLinejoin="round" />
|
<Path d="M4 20L21 3" stroke={c} strokeWidth={2} strokeLinecap="round" />
|
||||||
<Polyline points="21,16 21,21 16,21" stroke={c} strokeWidth={2.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.2} strokeLinecap="round" strokeLinejoin="round" />
|
<Path d="M15 15l5.1 5.1M4 4l5 5" stroke={c} strokeWidth={2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
|
function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
|
||||||
const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.6)';
|
const c = repeat > 0 ? Colors.accent : 'rgba(255,255,255,0.65)';
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
<Polyline points="17,1 21,5 17,9" stroke={c} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />
|
<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.2} strokeLinecap="round" strokeLinejoin="round" />
|
<Path d="M3 11V9a4 4 0 0 1 4-4h14" stroke={c} strokeWidth={2} strokeLinecap="round" />
|
||||||
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={2.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.2} strokeLinecap="round" strokeLinejoin="round" />
|
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
{repeat === 2 && <View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>}
|
{repeat === 2 && (
|
||||||
|
<View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(sec: number) {
|
function fmt(sec: number) {
|
||||||
|
if (!sec || isNaN(sec)) return '0:00';
|
||||||
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
|
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
|
||||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
root: { zIndex: 999 },
|
root: { zIndex: 999 },
|
||||||
bgDim: { backgroundColor: 'rgba(0,0,0,0.7)' },
|
bgDim: { backgroundColor: 'rgba(0,0,0,0.65)' },
|
||||||
sheet: { flex: 1, paddingHorizontal: 24 },
|
sheet: { flex: 1, paddingHorizontal: 28 },
|
||||||
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 10, marginBottom: 8 },
|
handleBar: { alignItems: 'center', paddingBottom: 8 },
|
||||||
topLabel: { color: 'rgba(255,255,255,0.4)', fontSize: 11, fontWeight: '700', letterSpacing: 1.3, textTransform: 'uppercase' },
|
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)' },
|
||||||
closeBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.12)', alignItems: 'center', justifyContent: 'center' },
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 },
|
||||||
coverWrap: { alignItems: 'center', marginVertical: 24, marginBottom: 32 },
|
headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||||
coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 28 }, shadowOpacity: 0.8, shadowRadius: 40 },
|
headerLabel: { color: Colors.text, fontSize: 13, fontWeight: '700', letterSpacing: 0.2 },
|
||||||
cover: { width: COVER, height: COVER, borderRadius: Radius.xl, backgroundColor: Colors.elevated, overflow: 'hidden' },
|
headerSub: { color: 'rgba(255,255,255,0.5)', fontSize: 11, marginTop: 1 },
|
||||||
metaRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 16, marginBottom: 16 },
|
mainArea: { flex: 1, alignItems: 'center', justifyContent: 'center', position: 'relative' },
|
||||||
metaText: { flex: 1 },
|
coverWrap: { alignItems: 'center', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center' },
|
||||||
trackTitle: { color: Colors.text, fontSize: 24, fontWeight: '800', letterSpacing: -0.5, lineHeight: 30 },
|
coverShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.75, shadowRadius: 36 },
|
||||||
artist: { color: 'rgba(255,255,255,0.6)', fontSize: 16, marginTop: 6, fontWeight: '500' },
|
cover: { width: COVER_SIZE, height: COVER_SIZE, borderRadius: 16, backgroundColor: Colors.elevated },
|
||||||
seekWrap: { marginBottom: 12 },
|
lyricsOverlay: { justifyContent: 'flex-start', paddingTop: 16 },
|
||||||
sliderContainer: { height: 40, justifyContent: 'center' },
|
lyricsCloseHint:{ alignItems: 'center', paddingVertical: 8, marginBottom: 8 },
|
||||||
timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 2 },
|
lyricsCloseText:{ color: 'rgba(255,255,255,0.45)', fontSize: 12, fontWeight: '600' },
|
||||||
timeText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '500' },
|
lyricsContent: { paddingHorizontal: 8, paddingBottom: 40 },
|
||||||
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 6, marginBottom: 24, marginTop: 16 },
|
lyrLine: { fontSize: 22, fontWeight: '700', color: 'rgba(255,255,255,0.28)', lineHeight: 34, marginBottom: 8, textAlign: 'left' },
|
||||||
ctlBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', borderRadius: 24 },
|
lyrLineActive: { fontSize: 28, color: '#ffffff', lineHeight: 40 },
|
||||||
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.08)' },
|
lyrLinePast: { color: 'rgba(255,255,255,0.45)' },
|
||||||
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 },
|
plainLyrics: { fontSize: 17, lineHeight: 28, color: 'rgba(255,255,255,0.8)', fontWeight: '500' },
|
||||||
bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
|
lyrEmpty: { color: 'rgba(255,255,255,0.4)', fontSize: 15, textAlign: 'center', marginTop: 60, fontWeight: '500' },
|
||||||
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)' },
|
meta: { flexDirection: 'row', alignItems: 'center', marginTop: 12, marginBottom: 4, gap: 12 },
|
||||||
lyrBtnActive: { backgroundColor: `${Colors.accent}15`, borderColor: `${Colors.accent}40` },
|
metaInfo: { flex: 1 },
|
||||||
lyrBtnText: { color: 'rgba(255,255,255,0.5)', fontSize: Font.sm, fontWeight: '600' },
|
trackTitle: { color: Colors.text, fontSize: 22, fontWeight: '800', letterSpacing: -0.4 },
|
||||||
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 },
|
artist: { color: 'rgba(255,255,255,0.55)', fontSize: 16, fontWeight: '500', marginTop: 4 },
|
||||||
|
likeBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
formatRow: { flexDirection: 'row', marginBottom: 4 },
|
||||||
|
formatBadge: { backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6 },
|
||||||
|
formatText: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '700', letterSpacing: 1 },
|
||||||
|
seekWrap: { marginBottom: 8 },
|
||||||
|
slider: { width: '100%', height: 44 },
|
||||||
|
timeRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -4 },
|
||||||
|
timeText: { color: 'rgba(255,255,255,0.4)', fontSize: 12, fontWeight: '500' },
|
||||||
|
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginVertical: 12 },
|
||||||
|
iconBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', borderRadius: 24 },
|
||||||
|
iconBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
|
||||||
|
playBtn: {
|
||||||
|
width: 72, height: 72, borderRadius: 36,
|
||||||
|
backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center',
|
||||||
|
shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.4, shadowRadius: 16,
|
||||||
|
},
|
||||||
|
bottomRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 4 },
|
||||||
|
lyrBtn: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 18, paddingVertical: 10,
|
||||||
|
borderRadius: 20, borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)',
|
||||||
|
},
|
||||||
|
lyrBtnActive: { borderColor: `${Colors.accent}50`, backgroundColor: `${Colors.accent}18` },
|
||||||
|
lyrBtnText: { color: 'rgba(255,255,255,0.55)', fontSize: 13, fontWeight: '600' },
|
||||||
|
repBadge: {
|
||||||
|
position: 'absolute', top: -3, right: -5, width: 11, height: 11,
|
||||||
|
borderRadius: 6, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center',
|
||||||
|
},
|
||||||
repBadgeText: { color: '#000', fontSize: 6, fontWeight: '900' },
|
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' },
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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' },
|
||||||
|
});
|
||||||
|
|
@ -16,7 +16,14 @@ async function ensureSetup() {
|
||||||
if (_setupDone) return;
|
if (_setupDone) return;
|
||||||
_setupDone = true;
|
_setupDone = true;
|
||||||
try {
|
try {
|
||||||
await TrackPlayer.setupPlayer({ minBuffer: 15, maxBuffer: 50, playBuffer: 2.5 });
|
await TrackPlayer.setupPlayer({
|
||||||
|
minBuffer: 15,
|
||||||
|
maxBuffer: 50,
|
||||||
|
playBuffer: 2.5,
|
||||||
|
// iOS: show full artwork on lock screen
|
||||||
|
iosCategory: 'playback' as any,
|
||||||
|
iosCategoryMode: 'default' as any,
|
||||||
|
});
|
||||||
await TrackPlayer.updateOptions({
|
await TrackPlayer.updateOptions({
|
||||||
android: {
|
android: {
|
||||||
appKilledPlaybackBehavior:
|
appKilledPlaybackBehavior:
|
||||||
|
|
@ -28,11 +35,23 @@ async function ensureSetup() {
|
||||||
Capability.SkipToNext,
|
Capability.SkipToNext,
|
||||||
Capability.SkipToPrevious,
|
Capability.SkipToPrevious,
|
||||||
Capability.SeekTo,
|
Capability.SeekTo,
|
||||||
|
Capability.Stop,
|
||||||
|
],
|
||||||
|
compactCapabilities: [
|
||||||
|
Capability.Play,
|
||||||
|
Capability.Pause,
|
||||||
|
Capability.SkipToNext,
|
||||||
|
],
|
||||||
|
// iOS lock screen: larger artwork
|
||||||
|
notificationCapabilities: [
|
||||||
|
Capability.Play,
|
||||||
|
Capability.Pause,
|
||||||
|
Capability.SkipToNext,
|
||||||
|
Capability.SkipToPrevious,
|
||||||
|
Capability.SeekTo,
|
||||||
],
|
],
|
||||||
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
|
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Player already set up on hot reload — update options only
|
|
||||||
if (e?.message?.toLowerCase().includes('already')) {
|
if (e?.message?.toLowerCase().includes('already')) {
|
||||||
_setupDone = true;
|
_setupDone = true;
|
||||||
return;
|
return;
|
||||||
|
|
@ -42,8 +61,6 @@ 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) {
|
export async function loadAndPlay(track: Track) {
|
||||||
if (_lastLoadedId === track.id) return;
|
if (_lastLoadedId === track.id) return;
|
||||||
_lastLoadedId = track.id;
|
_lastLoadedId = track.id;
|
||||||
|
|
@ -55,8 +72,10 @@ export async function loadAndPlay(track: Track) {
|
||||||
url: url || (track.cdn_url2 || track.cdn_url),
|
url: url || (track.cdn_url2 || track.cdn_url),
|
||||||
title: track.title,
|
title: track.title,
|
||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
|
// Pass artwork — TrackPlayer shows it on iOS lock screen & Now Playing
|
||||||
artwork: coverUrl(track.cover_url) ?? undefined,
|
artwork: coverUrl(track.cover_url) ?? undefined,
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
|
album: track.album ?? undefined,
|
||||||
});
|
});
|
||||||
await TrackPlayer.play();
|
await TrackPlayer.play();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
145
hooks/usePlayerFeatures.ts
Normal file
145
hooks/usePlayerFeatures.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
/**
|
||||||
|
* useNextTrackPreload — preloads next track URL 30s before end
|
||||||
|
* useDownloadQueue — sequential download queue (one at a time)
|
||||||
|
* useDynamicColor — extracts dominant color from cover art
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { ImageColors } from 'react-native-image-colors';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import TrackPlayer from 'react-native-track-player';
|
||||||
|
import { usePlayer, useOffline } from '../store';
|
||||||
|
import { coverUrl, getTrackUrl, Track } from '../services/api';
|
||||||
|
|
||||||
|
// ── Dynamic color from cover ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const colorCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function useDynamicColor(coverUri: string | null): string {
|
||||||
|
const [color, setColor] = useState('#1a1a2e');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!coverUri) { setColor('#1a1a2e'); return; }
|
||||||
|
if (colorCache.has(coverUri)) { setColor(colorCache.get(coverUri)!); return; }
|
||||||
|
|
||||||
|
ImageColors.getColors(coverUri, {
|
||||||
|
fallback: '#1a1a2e',
|
||||||
|
cache: true,
|
||||||
|
key: coverUri,
|
||||||
|
}).then((result) => {
|
||||||
|
let picked = '#1a1a2e';
|
||||||
|
if (result.platform === 'ios') {
|
||||||
|
picked = result.primary ?? result.background ?? picked;
|
||||||
|
} else if (result.platform === 'android') {
|
||||||
|
picked = result.vibrant ?? result.dominant ?? picked;
|
||||||
|
}
|
||||||
|
// Darken slightly for UI safety
|
||||||
|
colorCache.set(coverUri, picked);
|
||||||
|
setColor(picked);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [coverUri]);
|
||||||
|
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preload next track ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useNextTrackPreload() {
|
||||||
|
const { queue, index, progress, duration } = usePlayer();
|
||||||
|
const preloadedId = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = queue[index + 1];
|
||||||
|
if (!next) return;
|
||||||
|
if (preloadedId.current === next.id) return;
|
||||||
|
|
||||||
|
const timeLeft = duration - progress;
|
||||||
|
if (timeLeft > 30 || timeLeft <= 0) return;
|
||||||
|
|
||||||
|
preloadedId.current = next.id;
|
||||||
|
// Prefetch the URL so it's in the network cache
|
||||||
|
getTrackUrl(next).then((url) => {
|
||||||
|
if (url?.startsWith('http')) {
|
||||||
|
fetch(url, { method: 'HEAD' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [Math.floor(progress), index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sequential download queue ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DL_DIR = FileSystem.documentDirectory + 'tracks/';
|
||||||
|
|
||||||
|
interface QueueItem { track: Track; }
|
||||||
|
|
||||||
|
const downloadQueue: QueueItem[] = [];
|
||||||
|
let isProcessing = false;
|
||||||
|
|
||||||
|
async function processQueue(
|
||||||
|
setDownloading: (id: number, p: number) => void,
|
||||||
|
addDownloaded: (id: number) => void,
|
||||||
|
downloadedTracks: Set<number>,
|
||||||
|
) {
|
||||||
|
if (isProcessing || downloadQueue.length === 0) return;
|
||||||
|
isProcessing = true;
|
||||||
|
|
||||||
|
while (downloadQueue.length > 0) {
|
||||||
|
const item = downloadQueue[0];
|
||||||
|
const { track } = item;
|
||||||
|
|
||||||
|
if (downloadedTracks.has(track.id)) {
|
||||||
|
downloadQueue.shift();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await FileSystem.getInfoAsync(DL_DIR);
|
||||||
|
if (!info.exists) await FileSystem.makeDirectoryAsync(DL_DIR, { intermediates: true });
|
||||||
|
|
||||||
|
const url = track.cdn_url || track.cdn_url2;
|
||||||
|
if (!url) { downloadQueue.shift(); continue; }
|
||||||
|
|
||||||
|
const ext = (track.filename?.split('.').pop() || 'mp3');
|
||||||
|
const localPath = `${DL_DIR}${track.id}.${ext}`;
|
||||||
|
setDownloading(track.id, 0.01);
|
||||||
|
|
||||||
|
const dl = FileSystem.createDownloadResumable(url, localPath, {}, (dp) => {
|
||||||
|
const pct = dp.totalBytesExpectedToWrite > 0
|
||||||
|
? dp.totalBytesWritten / dp.totalBytesExpectedToWrite : 0;
|
||||||
|
setDownloading(track.id, pct);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dl.downloadAsync();
|
||||||
|
if (result?.uri) {
|
||||||
|
addDownloaded(track.id);
|
||||||
|
setDownloading(track.id, 1);
|
||||||
|
await FileSystem.writeAsStringAsync(`${DL_DIR}${track.id}.json`, JSON.stringify({
|
||||||
|
id: track.id, title: track.title, artist: track.artist,
|
||||||
|
filename: track.filename, cover_url: track.cover_url, localPath: result.uri,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDownloading(track.id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadQueue.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownloadQueue() {
|
||||||
|
const { setDownloading, addDownloadedTrack, downloadedTracks } = useOffline();
|
||||||
|
|
||||||
|
const enqueue = useCallback((track: Track) => {
|
||||||
|
if (downloadedTracks.has(track.id)) return;
|
||||||
|
if (downloadQueue.some(i => i.track.id === track.id)) return;
|
||||||
|
downloadQueue.push({ track });
|
||||||
|
processQueue(setDownloading, addDownloadedTrack, downloadedTracks);
|
||||||
|
}, [downloadedTracks, setDownloading, addDownloadedTrack]);
|
||||||
|
|
||||||
|
const enqueueAll = useCallback((tracks: Track[]) => {
|
||||||
|
tracks.forEach(enqueue);
|
||||||
|
}, [enqueue]);
|
||||||
|
|
||||||
|
return { enqueue, enqueueAll, queueLength: downloadQueue.length };
|
||||||
|
}
|
||||||
28
hooks/useShakeToShuffle.ts
Normal file
28
hooks/useShakeToShuffle.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* useShakeToShuffle — accelerometer-based shake detection
|
||||||
|
* Triggers callback when device is shaken hard enough
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Accelerometer } from 'expo-sensors';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
const SHAKE_THRESHOLD = 2.2;
|
||||||
|
const SHAKE_COOLDOWN = 1500; // ms
|
||||||
|
|
||||||
|
export function useShakeToShuffle(onShake: () => void) {
|
||||||
|
const lastShake = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Accelerometer.setUpdateInterval(100);
|
||||||
|
const sub = Accelerometer.addListener(({ x, y, z }) => {
|
||||||
|
const acc = Math.sqrt(x * x + y * y + z * z);
|
||||||
|
const now = Date.now();
|
||||||
|
if (acc > SHAKE_THRESHOLD && now - lastShake.current > SHAKE_COOLDOWN) {
|
||||||
|
lastShake.current = now;
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
onShake();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [onShake]);
|
||||||
|
}
|
||||||
57
main.yml
57
main.yml
|
|
@ -1,4 +1,4 @@
|
||||||
name: Build iOS IPA
|
name: NodeShiftMusic iOS Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -6,7 +6,8 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-ipa:
|
||||||
|
name: Build Unsigned IPA
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -20,37 +21,65 @@ jobs:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install CocoaPods
|
||||||
|
run: sudo gem install cocoapods --no-document
|
||||||
|
|
||||||
- name: Expo prebuild
|
- name: Expo prebuild
|
||||||
run: npx expo prebuild --platform ios --clean
|
|
||||||
env:
|
env:
|
||||||
EXPO_NO_DOTENV: 1
|
EXPO_NO_DOTENV: 1
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
|
||||||
|
- name: Install Pods
|
||||||
|
working-directory: ios
|
||||||
|
run: pod install --repo-update
|
||||||
|
|
||||||
|
- name: Find workspace & scheme
|
||||||
|
id: xcode
|
||||||
|
run: |
|
||||||
|
WS=$(find ios -name "*.xcworkspace" -maxdepth 2 | head -1)
|
||||||
|
SCHEME=$(basename "$WS" .xcworkspace)
|
||||||
|
echo "workspace=$WS" >> $GITHUB_OUTPUT
|
||||||
|
echo "scheme=$SCHEME" >> $GITHUB_OUTPUT
|
||||||
|
echo "Workspace: $WS | Scheme: $SCHEME"
|
||||||
|
|
||||||
- name: Build unsigned archive
|
- name: Build unsigned archive
|
||||||
run: |
|
run: |
|
||||||
xcodebuild archive \
|
xcodebuild archive \
|
||||||
-workspace "ios/Burmalda Music.xcworkspace" \
|
-workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||||
-scheme "Burmalda Music" \
|
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||||
-configuration Release \
|
-configuration Release \
|
||||||
-sdk iphoneos \
|
-sdk iphoneos \
|
||||||
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
|
-archivePath "$RUNNER_TEMP/NodeShiftMusic.xcarchive" \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
CODE_SIGN_IDENTITY="" \
|
CODE_SIGN_IDENTITY="" \
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
ENABLE_BITCODE=NO \
|
ENABLE_BITCODE=NO \
|
||||||
| xcpretty || true
|
2>&1 | xcpretty || true
|
||||||
|
|
||||||
- name: Package IPA
|
- name: Package IPA
|
||||||
run: |
|
run: |
|
||||||
|
APPS="$RUNNER_TEMP/NodeShiftMusic.xcarchive/Products/Applications"
|
||||||
mkdir -p Payload
|
mkdir -p Payload
|
||||||
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/
|
cp -r "$APPS/"*.app Payload/
|
||||||
zip -qr burmalda-unsigned.ipa Payload
|
zip -qr NodeShiftMusic-unsigned.ipa Payload
|
||||||
rm -rf Payload
|
rm -rf Payload
|
||||||
|
echo "IPA size: $(du -sh NodeShiftMusic-unsigned.ipa | cut -f1)"
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: burmalda-unsigned-ipa
|
name: NodeShiftMusic-unsigned
|
||||||
path: burmalda-unsigned.ipa
|
path: NodeShiftMusic-unsigned.ipa
|
||||||
retention-days: 30
|
retention-days: 90
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
echo "## NodeShiftMusic Build" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ -f NodeShiftMusic-unsigned.ipa ]; then
|
||||||
|
echo "IPA: $(du -sh NodeShiftMusic-unsigned.ipa | cut -f1)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Скачай из раздела Artifacts этого run'а" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
|
||||||
15454
package-lock.json
generated
Normal file
15454
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -34,7 +34,12 @@
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"react-native-svg": "^15.15.4",
|
"react-native-svg": "^15.15.4",
|
||||||
"react-native-track-player": "^4.1.1",
|
"react-native-track-player": "^4.1.1",
|
||||||
"zustand": "^4.5.4"
|
"zustand": "^4.5.4",
|
||||||
|
"react-native-image-colors": "^2.4.0",
|
||||||
|
"expo-sensors": "~13.0.9",
|
||||||
|
"expo-sharing": "~12.0.1",
|
||||||
|
"expo-clipboard": "~6.0.3",
|
||||||
|
"react-native-qrcode-svg": "^6.3.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.0",
|
"@babel/core": "^7.24.0",
|
||||||
|
|
|
||||||
77
services/mywave.ts
Normal file
77
services/mywave.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { Track } from './api';
|
||||||
|
import { useLikes } from '../store';
|
||||||
|
|
||||||
|
const DEEPSEEK_API_KEY = 'REDACTED_DEEPSEEK_API_KEY';
|
||||||
|
const DEEPSEEK_URL = 'https://api.deepseek.com/chat/completions';
|
||||||
|
|
||||||
|
export async function getMyWaveTracks(allTracks: Track[]): Promise<Track[]> {
|
||||||
|
const likedIds = [...useLikes.getState().likes];
|
||||||
|
|
||||||
|
if (likedIds.length === 0) {
|
||||||
|
// No liked tracks - return random shuffle
|
||||||
|
return [...allTracks].sort(() => Math.random() - 0.5).slice(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
const likedTracks = allTracks.filter((t) => likedIds.includes(t.id));
|
||||||
|
const likedNames = likedTracks
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((t) => `${t.artist} - ${t.title}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const otherTracks = allTracks.filter((t) => !likedIds.includes(t.id));
|
||||||
|
const otherNames = otherTracks
|
||||||
|
.slice(0, 80)
|
||||||
|
.map((t, i) => `${i}. ${t.artist} - ${t.title}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(DEEPSEEK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${DEEPSEEK_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'deepseek-chat',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: 'You are a music recommendation engine. Given liked songs, pick the most similar tracks from a list. Return ONLY a JSON array of track indices (numbers), maximum 25. No explanation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Liked songs:\n${likedNames}\n\nAvailable tracks (pick by index):\n${otherNames}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: 200,
|
||||||
|
temperature: 0.7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('DeepSeek API error');
|
||||||
|
const data = await res.json();
|
||||||
|
const content = data.choices?.[0]?.message?.content || '[]';
|
||||||
|
const match = content.match(/\[[\d,\s]+\]/);
|
||||||
|
if (match) {
|
||||||
|
const indices: number[] = JSON.parse(match[0]);
|
||||||
|
const recommended = indices
|
||||||
|
.filter((i) => i >= 0 && i < otherTracks.length)
|
||||||
|
.map((i) => otherTracks[i])
|
||||||
|
.filter(Boolean);
|
||||||
|
// Mix recommended with some liked
|
||||||
|
const mixed = [
|
||||||
|
...likedTracks.sort(() => Math.random() - 0.5).slice(0, 5),
|
||||||
|
...recommended,
|
||||||
|
].sort(() => Math.random() - 0.5);
|
||||||
|
return mixed.slice(0, 30);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('MyWave DeepSeek error:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: random mix with liked boosted
|
||||||
|
return [
|
||||||
|
...likedTracks.sort(() => Math.random() - 0.5),
|
||||||
|
...otherTracks.sort(() => Math.random() - 0.5).slice(0, 20),
|
||||||
|
].slice(0, 30);
|
||||||
|
}
|
||||||
70
setup-eas.sh
Normal file
70
setup-eas.sh
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# EAS Setup for GitHub Actions
|
||||||
|
# This script helps configure EAS_TOKEN for CI/CD
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔐 EAS Token Setup for GitHub Actions"
|
||||||
|
echo "===================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if eas-cli is installed
|
||||||
|
if ! command -v eas &> /dev/null; then
|
||||||
|
echo "❌ EAS CLI not found. Installing..."
|
||||||
|
npm install -g eas-cli
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📋 Step 1: Login to EAS"
|
||||||
|
echo "Open terminal and run: eas login"
|
||||||
|
echo "Then follow the prompts"
|
||||||
|
echo ""
|
||||||
|
read -p "Press Enter when done..."
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📋 Step 2: Get your EAS token"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
# macOS
|
||||||
|
TOKEN_FILE="$HOME/.eas-credentials"
|
||||||
|
elif [[ "$OSTYPE" == "msys" ]]; then
|
||||||
|
# Windows Git Bash
|
||||||
|
TOKEN_FILE="$USERPROFILE\.eas-credentials"
|
||||||
|
else
|
||||||
|
# Linux
|
||||||
|
TOKEN_FILE="$HOME/.eas-credentials"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$TOKEN_FILE" ]; then
|
||||||
|
echo "✓ Found credentials at: $TOKEN_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "Your EAS token:"
|
||||||
|
echo "================================"
|
||||||
|
cat "$TOKEN_FILE" | grep -o '"access_token":"[^"]*"' | head -1
|
||||||
|
echo "================================"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Copy the access_token value (between quotes)"
|
||||||
|
else
|
||||||
|
echo "❌ Credentials file not found at: $TOKEN_FILE"
|
||||||
|
echo "Make sure you ran: eas login"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📋 Step 3: Add to GitHub Secrets"
|
||||||
|
echo ""
|
||||||
|
echo "1. Go to: https://github.com/YOUR_USERNAME/nodeshiftmusicburmalda/settings/secrets/actions"
|
||||||
|
echo "2. Click 'New repository secret'"
|
||||||
|
echo "3. Name: EAS_TOKEN"
|
||||||
|
echo "4. Paste your token value"
|
||||||
|
echo "5. Click 'Add secret'"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "✅ Setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. git add ."
|
||||||
|
echo "2. git commit -m 'Setup GitHub Actions'"
|
||||||
|
echo "3. git push origin main"
|
||||||
|
echo ""
|
||||||
|
echo "Your app will automatically build on EAS!"
|
||||||
117
store/index.ts
117
store/index.ts
|
|
@ -6,9 +6,8 @@ import { Track, User } from '../services/api';
|
||||||
|
|
||||||
interface OfflineState {
|
interface OfflineState {
|
||||||
downloadedTracks: Set<number>;
|
downloadedTracks: Set<number>;
|
||||||
downloading: Map<number, number>; // trackId -> progress 0-1
|
downloading: Map<number, number>;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
|
|
||||||
addDownloadedTrack: (id: number) => void;
|
addDownloadedTrack: (id: number) => void;
|
||||||
removeDownloadedTrack: (id: number) => void;
|
removeDownloadedTrack: (id: number) => void;
|
||||||
setDownloading: (id: number, progress: number) => void;
|
setDownloading: (id: number, progress: number) => void;
|
||||||
|
|
@ -27,29 +26,22 @@ export const useOffline = create<OfflineState>((set, get) => ({
|
||||||
next.add(id);
|
next.add(id);
|
||||||
set({ downloadedTracks: next });
|
set({ downloadedTracks: next });
|
||||||
},
|
},
|
||||||
|
|
||||||
removeDownloadedTrack: (id) => {
|
removeDownloadedTrack: (id) => {
|
||||||
const next = new Set(get().downloadedTracks);
|
const next = new Set(get().downloadedTracks);
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
set({ downloadedTracks: next });
|
set({ downloadedTracks: next });
|
||||||
},
|
},
|
||||||
|
|
||||||
setDownloading: (id, progress) => {
|
setDownloading: (id, progress) => {
|
||||||
const next = new Map(get().downloading);
|
const next = new Map(get().downloading);
|
||||||
if (progress >= 1) {
|
if (progress >= 1) next.delete(id);
|
||||||
next.delete(id);
|
else next.set(id, progress);
|
||||||
} else {
|
|
||||||
next.set(id, progress);
|
|
||||||
}
|
|
||||||
set({ downloading: next });
|
set({ downloading: next });
|
||||||
},
|
},
|
||||||
|
|
||||||
clearDownloading: (id) => {
|
clearDownloading: (id) => {
|
||||||
const next = new Map(get().downloading);
|
const next = new Map(get().downloading);
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
set({ downloading: next });
|
set({ downloading: next });
|
||||||
},
|
},
|
||||||
|
|
||||||
loadDownloadedTracks: async () => {
|
loadDownloadedTracks: async () => {
|
||||||
try {
|
try {
|
||||||
const { getDownloadedTracks } = await import('../services/offline');
|
const { getDownloadedTracks } = await import('../services/offline');
|
||||||
|
|
@ -59,7 +51,6 @@ export const useOffline = create<OfflineState>((set, get) => ({
|
||||||
console.warn('Failed to load downloaded tracks:', err);
|
console.warn('Failed to load downloaded tracks:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setOnline: (online) => set({ isOnline: online }),
|
setOnline: (online) => set({ isOnline: online }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -158,13 +149,111 @@ export const useLikes = create<LikesState>((set, get) => ({
|
||||||
set({ likes: new Set(arr) });
|
set({ likes: new Set(arr) });
|
||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleLike: (id) => {
|
toggleLike: (id) => {
|
||||||
const next = new Set(get().likes);
|
const next = new Set(get().likes);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
set({ likes: next });
|
set({ likes: next });
|
||||||
AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {});
|
AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
isLiked: (id) => get().likes.has(id),
|
isLiked: (id) => get().likes.has(id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ── Hidden Tracks ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const HIDDEN_KEY = 'ns-music-hidden';
|
||||||
|
|
||||||
|
interface HiddenState {
|
||||||
|
hidden: Set<number>;
|
||||||
|
loadHidden: () => Promise<void>;
|
||||||
|
toggleHide: (id: number) => void;
|
||||||
|
isHidden: (id: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHidden = create<HiddenState>((set, get) => ({
|
||||||
|
hidden: new Set(),
|
||||||
|
|
||||||
|
loadHidden: async () => {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(HIDDEN_KEY);
|
||||||
|
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||||||
|
set({ hidden: new Set(arr) });
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
toggleHide: (id) => {
|
||||||
|
const next = new Set(get().hidden);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
set({ hidden: next });
|
||||||
|
AsyncStorage.setItem(HIDDEN_KEY, JSON.stringify([...next])).catch(() => {});
|
||||||
|
},
|
||||||
|
isHidden: (id) => get().hidden.has(id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Playlists ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PLAYLISTS_KEY = 'ns-music-playlists';
|
||||||
|
|
||||||
|
export interface Playlist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
trackIds: number[];
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistState {
|
||||||
|
playlists: Playlist[];
|
||||||
|
loadPlaylists: () => Promise<void>;
|
||||||
|
createPlaylist: (name: string) => Playlist;
|
||||||
|
deletePlaylist: (id: string) => void;
|
||||||
|
renamePlaylist: (id: string, name: string) => void;
|
||||||
|
addToPlaylist: (playlistId: string, trackId: number) => void;
|
||||||
|
removeFromPlaylist: (playlistId: string, trackId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlaylists = create<PlaylistState>((set, get) => ({
|
||||||
|
playlists: [],
|
||||||
|
|
||||||
|
loadPlaylists: async () => {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(PLAYLISTS_KEY);
|
||||||
|
const arr: Playlist[] = raw ? JSON.parse(raw) : [];
|
||||||
|
set({ playlists: arr });
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
createPlaylist: (name) => {
|
||||||
|
const pl: Playlist = { id: `pl_${Date.now()}`, name, trackIds: [], createdAt: Date.now() };
|
||||||
|
const next = [...get().playlists, pl];
|
||||||
|
set({ playlists: next });
|
||||||
|
AsyncStorage.setItem(PLAYLISTS_KEY, JSON.stringify(next)).catch(() => {});
|
||||||
|
return pl;
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePlaylist: (id) => {
|
||||||
|
const next = get().playlists.filter((p) => p.id !== id);
|
||||||
|
set({ playlists: next });
|
||||||
|
AsyncStorage.setItem(PLAYLISTS_KEY, JSON.stringify(next)).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
renamePlaylist: (id, name) => {
|
||||||
|
const next = get().playlists.map((p) => p.id === id ? { ...p, name } : p);
|
||||||
|
set({ playlists: next });
|
||||||
|
AsyncStorage.setItem(PLAYLISTS_KEY, JSON.stringify(next)).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
addToPlaylist: (playlistId, trackId) => {
|
||||||
|
const next = get().playlists.map((p) => {
|
||||||
|
if (p.id !== playlistId || p.trackIds.includes(trackId)) return p;
|
||||||
|
return { ...p, trackIds: [...p.trackIds, trackId] };
|
||||||
|
});
|
||||||
|
set({ playlists: next });
|
||||||
|
AsyncStorage.setItem(PLAYLISTS_KEY, JSON.stringify(next)).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromPlaylist: (playlistId, trackId) => {
|
||||||
|
const next = get().playlists.map((p) =>
|
||||||
|
p.id !== playlistId ? p : { ...p, trackIds: p.trackIds.filter((id) => id !== trackId) }
|
||||||
|
);
|
||||||
|
set({ playlists: next });
|
||||||
|
AsyncStorage.setItem(PLAYLISTS_KEY, JSON.stringify(next)).catch(() => {});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,6 @@
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["app", "components", "constants", "hooks", "services", "store"],
|
"include": ["app", "components", "constants", "hooks", "services", "store", "types"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
types/modules.d.ts
vendored
Normal file
54
types/modules.d.ts
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Type stubs for packages installed at build time
|
||||||
|
declare module 'expo-clipboard' {
|
||||||
|
export function setStringAsync(text: string): Promise<void>;
|
||||||
|
export function getStringAsync(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'expo-sharing' {
|
||||||
|
export function isAvailableAsync(): Promise<boolean>;
|
||||||
|
export function shareAsync(url: string, options?: { dialogTitle?: string; mimeType?: string }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'expo-sensors' {
|
||||||
|
export const Accelerometer: {
|
||||||
|
setUpdateInterval(ms: number): void;
|
||||||
|
addListener(callback: (data: { x: number; y: number; z: number }) => void): { remove(): void };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'react-native-image-colors' {
|
||||||
|
export type ImageColorsResult =
|
||||||
|
| { platform: 'ios'; primary?: string; secondary?: string; background?: string; detail?: string }
|
||||||
|
| { platform: 'android'; vibrant?: string; dominant?: string; darkVibrant?: string; lightVibrant?: string; darkMuted?: string; lightMuted?: string; muted?: string };
|
||||||
|
|
||||||
|
export const ImageColors: {
|
||||||
|
getColors(
|
||||||
|
uri: string,
|
||||||
|
config?: { fallback?: string; cache?: boolean; key?: string }
|
||||||
|
): Promise<ImageColorsResult>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'react-native-qrcode-svg' {
|
||||||
|
import { ComponentType } from 'react';
|
||||||
|
interface QRCodeProps {
|
||||||
|
value: string;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
logo?: any;
|
||||||
|
logoSize?: number;
|
||||||
|
logoBackgroundColor?: string;
|
||||||
|
logoMargin?: number;
|
||||||
|
logoBorderRadius?: number;
|
||||||
|
quietZone?: number;
|
||||||
|
enableLinearGradient?: boolean;
|
||||||
|
gradientDirection?: string[];
|
||||||
|
linearGradient?: string[];
|
||||||
|
ecl?: 'L' | 'M' | 'Q' | 'H';
|
||||||
|
getRef?: (ref: any) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
const QRCode: ComponentType<QRCodeProps>;
|
||||||
|
export default QRCode;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue