Add files via upload

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

View file

@ -1,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"],

View file

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

View file

@ -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>
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 2 }}>
<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)' },

View file

@ -1,140 +1,422 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, StyleSheet, TextInput, Pressable } from 'react-native'; import {
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; View, Text, FlatList, StyleSheet, Pressable, Alert, TextInput, Modal,
import { useSafeAreaInsets } from 'react-native-safe-area-context'; ActionSheetIOS, Platform,
import { Image } from 'expo-image'; } from 'react-native';
import Svg, { Circle, Line, Path } from 'react-native-svg'; import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import * as Haptics from 'expo-haptics'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme'; import { Image } from 'expo-image';
import { getTracks, Track, coverUrl } from '../../services/api'; import Svg, { Path, Circle, Line, Rect } from 'react-native-svg';
import { usePlayer, useLikes } from '../../store'; import * as Haptics from 'expo-haptics';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
export default function LibraryScreen() { import { getTracks, Track, coverUrl } from '../../services/api';
const [all, setAll] = useState<Track[]>([]); import { usePlayer, useLikes, useHidden, usePlaylists, Playlist } from '../../store';
const [filtered, setFiltered] = useState<Track[]>([]); import { getMyWaveTracks } from '../../services/mywave';
const [query, setQuery] = useState(''); import TrackDownloadButton from '../../components/DownloadButton';
const insets = useSafeAreaInsets(); import WaveAnimation from '../../components/WaveAnimation';
const { setQueue } = usePlayer();
const { toggleLike, isLiked } = useLikes(); type Tab = 'tracks' | 'playlists' | 'wave';
useEffect(() => { export default function LibraryScreen() {
getTracks().then(t => { setAll(t); setFiltered(t); }); const [all, setAll] = useState<Track[]>([]);
}, []); const [query, setQuery] = useState('');
const [tab, setTab] = useState<Tab>('tracks');
useEffect(() => { const [wave, setWave] = useState<Track[]>([]);
const q = query.toLowerCase(); const [waveLoading, setWaveLoading] = useState(false);
setFiltered(q ? all.filter(t => const [newPlName, setNewPlName] = useState('');
t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q) const [showNewPl, setShowNewPl] = useState(false);
) : all); const insets = useSafeAreaInsets();
}, [query, all]); const { setQueue } = usePlayer();
const { toggleLike, isLiked } = useLikes();
const playTrack = (idx: number) => { const { isHidden, toggleHide } = useHidden();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); const { playlists, createPlaylist, deletePlaylist, renamePlaylist } = usePlaylists();
setQueue(filtered, idx);
usePlayer.getState().setExpanded(true); useEffect(() => {
}; getTracks().then(t => setAll(t));
}, []);
return (
<View style={[styles.container, { paddingTop: insets.top }]}> const visible = all.filter(t => {
{/* Header */} if (isHidden(t.id)) return false;
<View style={styles.header}> if (!query) return true;
<Text style={styles.title}>Библиотека</Text> const q = query.toLowerCase();
<Text style={styles.count}>{all.length} треков</Text> return t.title.toLowerCase().includes(q) || t.artist.toLowerCase().includes(q);
</View> });
{/* Search */} const loadWave = useCallback(async () => {
<View style={styles.searchWrap}> if (wave.length > 0) { setTab('wave'); return; }
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none" style={{ marginLeft: 12 }}> setWaveLoading(true);
<Circle cx={11} cy={11} r={8} stroke={Colors.text3} strokeWidth={1.8} /> const tracks = await getMyWaveTracks(all);
<Line x1="21" y1="21" x2="16.65" y2="16.65" stroke={Colors.text3} strokeWidth={1.8} strokeLinecap="round" /> setWave(tracks);
</Svg> setWaveLoading(false);
<TextInput setTab('wave');
style={styles.searchInput} }, [all, wave.length]);
placeholder="Поиск треков..."
placeholderTextColor={Colors.text3} const playTrack = (list: Track[], idx: number) => {
value={query} Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onChangeText={setQuery} setQueue(list, idx);
returnKeyType="search" usePlayer.getState().setExpanded(true);
autoCorrect={false} };
/>
{!!query && ( const handleLongPress = (track: Track) => {
<Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}> Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
<Text style={{ color: Colors.text3, fontSize: 12 }}>✕</Text> const hidden = isHidden(track.id);
</Pressable> const options = [
)} hidden ? 'Показать трек' : 'Скрыть из библиотеки',
</View> isLiked(track.id) ? 'Убрать из избранного' : 'В избранное',
'Отмена',
<FlatList ];
data={filtered} if (Platform.OS === 'ios') {
keyExtractor={t => String(t.id)} ActionSheetIOS.showActionSheetWithOptions(
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }} { options, cancelButtonIndex: 2, destructiveButtonIndex: 0 },
showsVerticalScrollIndicator={false} (i) => {
renderItem={({ item, index }) => ( if (i === 0) toggleHide(track.id);
<TrackItem if (i === 1) toggleLike(track.id);
track={item} }
index={index} );
liked={isLiked(item.id)} } else {
onPress={() => playTrack(index)} Alert.alert(track.title, undefined, [
onLike={() => toggleLike(item.id)} { text: hidden ? 'Показать' : 'Скрыть', onPress: () => toggleHide(track.id), style: 'destructive' },
/> { text: isLiked(track.id) ? 'Убрать из избранного' : 'В избранное', onPress: () => toggleLike(track.id) },
)} { text: 'Отмена', style: 'cancel' },
/> ]);
</View> }
); };
}
const handlePlaylistLongPress = (pl: Playlist) => {
function TrackItem({ track, index, liked, onPress, onLike }: { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
track: Track; index: number; liked: boolean; onPress: () => void; onLike: () => void; const options = ['Переименовать', 'Удалить', 'Отмена'];
}) { if (Platform.OS === 'ios') {
const { queue, index: qi } = usePlayer(); ActionSheetIOS.showActionSheetWithOptions(
const active = queue[qi]?.id === track.id; { options, cancelButtonIndex: 2, destructiveButtonIndex: 1 },
const cover = coverUrl(track.cover_url); (i) => {
if (i === 0) {
return ( Alert.prompt('Переименовать', undefined, (name) => {
<Animated.View entering={FadeInDown.delay(Math.min(index * 25, 300)).duration(300)}> if (name?.trim()) renamePlaylist(pl.id, name.trim());
<Pressable }, undefined, pl.name);
onPress={onPress} }
style={[styles.row, active && styles.rowActive]} if (i === 1) deletePlaylist(pl.id);
> }
<Text style={styles.num}>{index + 1}</Text> );
<Image } else {
source={cover ? { uri: cover } : require('../../assets/placeholder.png')} Alert.alert(pl.name, undefined, [
style={styles.cover} { text: 'Удалить', onPress: () => deletePlaylist(pl.id), style: 'destructive' },
contentFit="cover" { text: 'Отмена', style: 'cancel' },
transition={200} ]);
/> }
<View style={{ flex: 1 }}> };
<Text style={[styles.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</Text>
<Text style={styles.artist} numberOfLines={1}>{track.artist}</Text> return (
</View> <View style={[s.root, { paddingTop: insets.top }]}>
<Pressable onPress={onLike} hitSlop={12}> {/* Header */}
<Svg width={16} height={16} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}> <View style={s.header}>
<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" /> <Text style={s.title}>Библиотека</Text>
</Svg> <Pressable onPress={() => setShowNewPl(true)} style={s.addBtn} hitSlop={12}>
</Pressable> <Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Text style={styles.dur}>{fmt(track.duration)}</Text> <Path d="M12 5v14M5 12h14" stroke={Colors.accent} strokeWidth={2.2} strokeLinecap="round" />
</Pressable> </Svg>
</Animated.View> </Pressable>
); </View>
}
{/* Tabs */}
function fmt(s: number) { <View style={s.tabRow}>
const m = Math.floor(s / 60), sec = Math.floor(s % 60); {(['tracks', 'playlists', 'wave'] as Tab[]).map(t => (
return `${m}:${sec < 10 ? '0' : ''}${sec}`; <Pressable
} key={t}
style={[s.tabBtn, tab === t && s.tabBtnActive]}
const styles = StyleSheet.create({ onPress={() => t === 'wave' ? loadWave() : setTab(t)}
container: { flex: 1, backgroundColor: Colors.bg }, >
header: { flexDirection: 'row', alignItems: 'baseline', gap: Spacing.sm, paddingHorizontal: Spacing.md, paddingTop: Spacing.md, paddingBottom: Spacing.sm }, <Text style={[s.tabText, tab === t && s.tabTextActive]}>
title: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, flex: 1 }, {t === 'tracks' ? 'Треки' : t === 'playlists' ? 'Плейлисты' : 'Моя волна'}
count: { color: Colors.text3, fontSize: Font.sm }, </Text>
searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.surface, borderRadius: Radius.md, marginHorizontal: Spacing.md, marginBottom: Spacing.md, gap: 8 }, </Pressable>
searchInput:{ flex: 1, color: Colors.text, fontSize: Font.md, paddingVertical: 11, paddingRight: Spacing.md }, ))}
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: Spacing.sm, borderRadius: Radius.sm, paddingHorizontal: 4 }, </View>
rowActive: { backgroundColor: Colors.surface },
num: { width: 22, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' }, {/* Search (only on tracks tab) */}
cover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated }, {tab === 'tracks' && (
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, <View style={s.searchWrap}>
artist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 }, <Svg width={14} height={14} viewBox="0 0 24 24" fill="none">
dur: { color: Colors.text3, fontSize: Font.xs }, <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" />
</Svg>
<TextInput
style={s.searchInput}
placeholder="Поиск..."
placeholderTextColor={Colors.text3}
value={query}
onChangeText={setQuery}
returnKeyType="search"
autoCorrect={false}
/>
{!!query && (
<Pressable onPress={() => setQuery('')} hitSlop={12} style={{ paddingRight: 12 }}>
<Text style={{ color: Colors.text3, fontSize: 13 }}>x</Text>
</Pressable>
)}
</View>
)}
{/* Content */}
{tab === 'tracks' && (
<FlatList
data={visible}
keyExtractor={t => String(t.id)}
contentContainerStyle={{ paddingHorizontal: Spacing.md, paddingBottom: TAB_BAR_HEIGHT + MINI_PLAYER_HEIGHT + 24 }}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
index={index}
liked={isLiked(item.id)}
onPress={() => playTrack(visible, index)}
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>
);
}
function TrackRow({ track, index, liked, onPress, onLike, onLongPress }: {
track: Track; index: number; liked: boolean;
onPress: () => void; onLike: () => void; onLongPress: () => void;
}) {
const { queue, index: qi } = usePlayer();
const active = queue[qi]?.id === track.id;
const cover = coverUrl(track.cover_url);
const ext = (track.filename?.split('.').pop() || 'MP3').toUpperCase();
return (
<Animated.View entering={FadeInDown.delay(Math.min(index * 20, 300)).duration(250)}>
<Pressable
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={350}
style={[s.row, active && s.rowActive]}
>
<Image
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
style={s.cover}
contentFit="cover"
transition={200}
/>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={[s.trackTitle, active && { color: Colors.accent }]} numberOfLines={1}>{track.title}</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>
<TrackDownloadButton track={track} size={18} />
<Pressable onPress={onLike} hitSlop={12}>
<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" />
</Svg>
</Pressable>
</Pressable>
</Animated.View>
);
}
function PlaylistRow({ playlist, tracks, onPress, onLongPress }: {
playlist: Playlist; tracks: Track[]; onPress: () => void; onLongPress: () => void;
}) {
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>
);
}
function EmptyState({ text }: { text: string }) {
return (
<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 },
addBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: Colors.surface, alignItems: 'center', justifyContent: 'center' },
tabRow: { flexDirection: 'row', paddingHorizontal: Spacing.md, gap: 8, marginBottom: 12 },
tabBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: Radius.full, backgroundColor: Colors.surface },
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 },
cover: { width: 46, height: 46, borderRadius: Radius.sm, backgroundColor: Colors.elevated, flexShrink: 0 },
trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: Colors.text2, fontSize: Font.sm, flex: 1 },
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' },
});

View file

@ -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,
import * as Haptics from 'expo-haptics'; } from 'react-native-reanimated';
import { Colors, Font } from '../constants/theme'; import Svg, { Path, Circle } from 'react-native-svg';
import * as Haptics from 'expo-haptics';
export interface DownloadButtonProps { import * as FileSystem from 'expo-file-system';
trackId: number; import { Colors, Radius } from '../constants/theme';
isDownloaded?: boolean; import { useOffline } from '../store';
downloadProgress?: number; // 0-1 import { Track, coverUrl } from '../services/api';
onDownload?: () => void;
onDelete?: () => void; interface Props {
size?: 'sm' | 'md' | 'lg'; track: Track;
} size?: number;
showLabel?: boolean;
export default function DownloadButton({ }
trackId,
isDownloaded = false, const DL_DIR = FileSystem.documentDirectory + 'tracks/';
downloadProgress = 0,
onDownload, async function ensureDir() {
onDelete, const info = await FileSystem.getInfoAsync(DL_DIR);
size = 'md', if (!info.exists) await FileSystem.makeDirectoryAsync(DL_DIR, { intermediates: true });
}: DownloadButtonProps) { }
const sizing = useMemo(() => {
const map = { sm: 20, md: 24, lg: 32 }; function getLocalPath(trackId: number, filename: string) {
return map[size]; const ext = filename.split('.').pop() || 'mp3';
}, [size]); return `${DL_DIR}${trackId}.${ext}`;
}
const handlePress = async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); export default function TrackDownloadButton({ track, size = 22, showLabel = false }: Props) {
if (isDownloaded) { const { downloadedTracks, downloading, setDownloading, addDownloadedTrack } = useOffline();
onDelete?.(); const isDownloaded = downloadedTracks.has(track.id);
} else { const dlProgress = downloading.get(track.id);
onDownload?.(); const isDownloading = dlProgress !== undefined;
}
}; const scale = useSharedValue(1);
const scaleStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const isLoading = downloadProgress > 0 && downloadProgress < 1;
const progressWidth = useAnimatedStyle(() => ({
if (isLoading) { width: withTiming(`${(dlProgress ?? 0) * 100}%` as any, { duration: 150 }),
return ( }));
<Animated.View entering={FadeIn}>
<View style={[styles.container, { width: sizing, height: sizing }]}> const startDownload = useCallback(async () => {
<ActivityIndicator color={Colors.accent} size={sizing * 0.6} /> if (isDownloaded || isDownloading) return;
{downloadProgress > 0.2 && ( Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
<Text style={[styles.progress, { fontSize: sizing * 0.3 }]}> scale.value = withSpring(0.85, { damping: 8 }, () => { scale.value = withSpring(1); });
{Math.round(downloadProgress * 100)}%
</Text> try {
)} await ensureDir();
</View> const url = track.cdn_url || track.cdn_url2;
</Animated.View> if (!url) { Alert.alert('Ошибка', 'URL трека недоступен'); return; }
);
} const localPath = getLocalPath(track.id, track.filename || `${track.id}.mp3`);
setDownloading(track.id, 0.01);
return (
<Pressable const callback = (dp: FileSystem.DownloadProgressData) => {
onPress={handlePress} const pct = dp.totalBytesExpectedToWrite > 0
style={({ pressed }) => [ ? dp.totalBytesWritten / dp.totalBytesExpectedToWrite
styles.button, : 0;
{ setDownloading(track.id, pct);
width: sizing, };
height: sizing,
opacity: pressed ? 0.6 : 1, const downloadResumable = FileSystem.createDownloadResumable(url, localPath, {}, callback);
}, const result = await downloadResumable.downloadAsync();
]}
> if (result?.uri) {
{isDownloaded ? ( addDownloadedTrack(track.id);
<Svg width={sizing} height={sizing} viewBox="0 0 24 24" fill="none"> setDownloading(track.id, 1);
<Path // Save metadata locally
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" const metaPath = `${DL_DIR}${track.id}.json`;
fill={Colors.accent} await FileSystem.writeAsStringAsync(metaPath, JSON.stringify({
/> id: track.id, title: track.title, artist: track.artist,
</Svg> filename: track.filename, cover_url: track.cover_url,
) : ( localPath: result.uri,
<Svg width={sizing} height={sizing} viewBox="0 0 24 24" fill="none"> }));
<Path Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" }
fill={Colors.text2} } catch (e: any) {
/> setDownloading(track.id, 1);
</Svg> Alert.alert('Ошибка загрузки', e?.message || 'Попробуйте снова');
)} }
</Pressable> }, [track, isDownloaded, isDownloading]);
);
} const iconColor = isDownloaded
? Colors.accent
const styles = StyleSheet.create({ : isDownloading
button: { ? 'rgba(255,255,255,0.4)'
justifyContent: 'center', : 'rgba(255,255,255,0.7)';
alignItems: 'center',
}, return (
container: { <Animated.View style={scaleStyle}>
justifyContent: 'center', <Pressable onPress={startDownload} style={s.btn} hitSlop={12}>
alignItems: 'center', {isDownloading ? (
}, <View style={s.progressWrap}>
progress: { <View style={s.progressBg}>
color: Colors.accent, <Animated.View style={[s.progressFill, progressWidth]} />
fontFamily: 'System', </View>
fontWeight: '600', <Text style={s.pctText}>{Math.round((dlProgress ?? 0) * 100)}%</Text>
marginTop: 2, </View>
}, ) : (
}); <View style={{ alignItems: 'center', flexDirection: 'row', gap: 4 }}>
{isDownloaded ? (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Circle cx={12} cy={12} r={10} stroke={Colors.accent} strokeWidth={1.5} />
<Path d="M8 12l3 3 5-5" stroke={Colors.accent} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
) : (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path d="M12 3v13M7 11l5 5 5-5" stroke={iconColor} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M5 20h14" stroke={iconColor} strokeWidth={2} strokeLinecap="round" />
</Svg>
)}
{showLabel && (
<Text style={[s.label, { color: iconColor }]}>
{isDownloaded ? 'Скачано' : 'Скачать'}
</Text>
)}
</View>
)}
</Pressable>
</Animated.View>
);
}
const s = StyleSheet.create({
btn: { alignItems: 'center', justifyContent: 'center' },
progressWrap: { alignItems: 'center', gap: 4 },
progressBg: { width: 48, height: 3, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.12)', overflow: 'hidden' },
progressFill: { height: 3, backgroundColor: Colors.accent, borderRadius: 2 },
pctText: { color: Colors.accent, fontSize: 9, fontWeight: '700' },
label: { fontSize: 12, fontWeight: '600' },
});

View file

@ -1,129 +1,162 @@
import { useCallback } from 'react'; import { 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,
} from 'react-native-reanimated'; cancelAnimation,
import { PanGestureHandler } from 'react-native-gesture-handler'; } from 'react-native-reanimated';
import { BlurView } from 'expo-blur'; import { PanGestureHandler } from 'react-native-gesture-handler';
import { Image } from 'expo-image'; import { BlurView } from 'expo-blur';
import * as Haptics from 'expo-haptics'; import { Image } from 'expo-image';
import Svg, { Path } from 'react-native-svg'; import * as Haptics from 'expo-haptics';
import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme'; import Svg, { Path, Rect } from 'react-native-svg';
import { usePlayer, useLikes } from '../store'; import { Colors, Font, MINI_PLAYER_HEIGHT, TAB_BAR_HEIGHT } from '../constants/theme';
import { togglePlay, seekTo } from '../hooks/useAudio'; import { usePlayer, useLikes } from '../store';
import { coverUrl } from '../services/api'; import { togglePlay } from '../hooks/useAudio';
import { coverUrl } from '../services/api';
export default function MiniPlayer() { import SpectrumBars from './SpectrumBars';
const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer();
const { toggleLike, isLiked } = useLikes(); export default function MiniPlayer() {
const track = queue[index]; const { queue, index, playing, expanded, setExpanded, progress, duration } = usePlayer();
const { toggleLike, isLiked } = useLikes();
const translateY = useSharedValue(0); const track = queue[index];
const opacity = useSharedValue(1);
const translateY = useSharedValue(0);
const gestureHandler = useAnimatedGestureHandler({ const opacity = useSharedValue(1);
onActive: (e) => {
if (e.translationY < 0) { const openPlayer = useCallback(() => {
translateY.value = Math.max(e.translationY, -80); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
opacity.value = 1 + e.translationY / 80; setExpanded(true);
} translateY.value = withSpring(0, { damping: 20, stiffness: 200 });
}, opacity.value = withSpring(1);
onEnd: (e) => { }, [setExpanded]);
if (e.translationY < -40 || e.velocityY < -400) runOnJS(openPlayer)();
translateY.value = withSpring(0); const gestureHandler = useAnimatedGestureHandler<any, { start: number }>({
opacity.value = withSpring(1); onStart: (_, ctx) => {
}, cancelAnimation(translateY);
}); ctx.start = translateY.value;
},
const animStyle = useAnimatedStyle(() => ({ onActive: (e) => {
transform: [{ translateY: translateY.value }], if (e.translationY > 0) return; // block downward
opacity: opacity.value, const dy = Math.max(e.translationY, -100);
})); translateY.value = dy;
opacity.value = interpolate(dy, [-100, 0], [0.6, 1], Extrapolate.CLAMP);
const openPlayer = useCallback(() => { },
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); onEnd: (e) => {
setExpanded(true); const shouldOpen = e.translationY < -40 || e.velocityY < -600;
}, [setExpanded]); if (shouldOpen) {
translateY.value = withTiming(-MINI_PLAYER_HEIGHT, {
const onNext = useCallback(() => { duration: 200, easing: Easing.out(Easing.cubic),
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }, () => runOnJS(openPlayer)());
const ni = usePlayer.getState().nextTrack(); } else {
usePlayer.getState().setIndex(ni); translateY.value = withSpring(0, { damping: 22, stiffness: 250 });
}, []); opacity.value = withSpring(1);
}
const onPrev = useCallback(() => { },
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); onFail: () => {
const pi = usePlayer.getState().prevTrack(); translateY.value = withSpring(0, { damping: 22, stiffness: 250 });
usePlayer.getState().setIndex(pi); opacity.value = withSpring(1);
}, []); },
onCancel: () => {
if (!track || expanded) return null; translateY.value = withSpring(0, { damping: 22, stiffness: 250 });
opacity.value = withSpring(1);
const cover = coverUrl(track.cover_url); },
const liked = isLiked(track.id); });
const pct = duration > 0 ? (progress / duration) * 100 : 0;
const animStyle = useAnimatedStyle(() => ({
return ( transform: [{ translateY: translateY.value }],
<PanGestureHandler onGestureEvent={gestureHandler}> opacity: opacity.value,
<Animated.View }));
style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}
entering={FadeInDown.springify()} const onNext = useCallback(() => {
exiting={FadeOutDown.springify()} Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
> const ni = usePlayer.getState().nextTrack();
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} /> usePlayer.getState().setIndex(ni);
<View style={[StyleSheet.absoluteFill, s.bgTint]} /> }, []);
{/* Progress line */} if (!track || expanded) return null;
<View style={s.progressBg}>
<View style={[s.progressFill, { width: `${pct}%` as any }]} /> const cover = coverUrl(track.cover_url);
</View> const pct = duration > 0 ? (progress / duration) * 100 : 0;
const liked = isLiked(track.id);
<Pressable style={s.row} onPress={openPlayer} android_ripple={null}>
<Image return (
source={cover ? { uri: cover } : require('../assets/placeholder.png')} <PanGestureHandler
style={s.cover} onGestureEvent={gestureHandler}
contentFit="cover" activeOffsetY={[-8, 8]}
transition={200} failOffsetX={[-20, 20]}
/> >
<View style={s.info}> <Animated.View style={[s.wrap, animStyle, { bottom: TAB_BAR_HEIGHT }]}>
<Text style={s.title} numberOfLines={1}>{track.title}</Text> <BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text> <View style={[StyleSheet.absoluteFill, s.bgTint]} />
</View>
<View style={s.controls}> {/* Progress bar */}
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}> <View style={s.progressBg}>
<Svg width={18} height={18} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}> <View style={[s.progressFill, { width: `${pct}%` as any }]} />
<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" /> </View>
</Svg>
</Pressable> <Pressable style={s.row} onPress={openPlayer}>
<Pressable onPress={(e) => { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}> <Image
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></Svg> source={cover ? { uri: cover } : require('../assets/placeholder.png')}
</Pressable> style={s.cover}
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}> contentFit="cover"
{playing transition={200}
? <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></Svg> />
: <Svg width={18} height={18} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>} <View style={s.info}>
</Pressable> <Text style={s.title} numberOfLines={1}>{track.title}</Text>
<Pressable onPress={(e) => { e.stopPropagation(); onNext(); }} hitSlop={10} style={s.ctlBtn}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg> {playing && <SpectrumBars playing={playing} color={Colors.accent} barCount={4} height={14} />}
</Pressable> <Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
</View> </View>
</Pressable> </View>
</Animated.View> <View style={s.controls}>
</PanGestureHandler> <Pressable
); onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }}
} hitSlop={12} style={s.ctlBtn}
>
const s = StyleSheet.create({ <Svg width={20} height={20} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderTopLeftRadius: 14, borderTopRightRadius: 14 }, <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" />
bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' }, </Svg>
progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 }, </Pressable>
progressFill:{ height: 2, backgroundColor: Colors.accent }, <Pressable
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 4, gap: 12 }, onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }}
cover: { width: 44, height: 44, borderRadius: 9, backgroundColor: Colors.elevated, flexShrink: 0 }, hitSlop={12} style={s.ctlBtn}
info: { flex: 1, minWidth: 0 }, >
title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' }, {playing
artist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 }, ? <Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.text}>
controls: { flexDirection: 'row', alignItems: 'center' }, <Rect x={5} y={4} width={4} height={16} rx={1.5} fill={Colors.text} />
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, <Rect x={15} y={4} width={4} height={16} rx={1.5} fill={Colors.text} />
}); </Svg>
: <Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.text}>
<Path d="M8 5v14l11-7z" />
</Svg>
}
</Pressable>
<Pressable
onPress={(e) => { e.stopPropagation(); onNext(); }}
hitSlop={12} style={s.ctlBtn}
>
<Svg width={22} height={22} viewBox="0 0 24 24" fill={Colors.text}>
<Path d="M5 4l10 8-10 8V4zM19 4h-2v16h2z" />
</Svg>
</Pressable>
</View>
</Pressable>
</Animated.View>
</PanGestureHandler>
);
}
const s = StyleSheet.create({
wrap: { position: 'absolute', left: 8, right: 8, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderRadius: 16 },
bgTint: { backgroundColor: 'rgba(18,18,20,0.5)' },
progressBg: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.06)', zIndex: 2 },
progressFill: { height: 2, backgroundColor: Colors.accent },
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingLeft: 12, paddingRight: 2, gap: 10 },
cover: { width: 46, height: 46, borderRadius: 10, backgroundColor: Colors.elevated },
info: { flex: 1, minWidth: 0 },
title: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
artist: { color: 'rgba(255,255,255,0.5)', fontSize: Font.xs, marginTop: 2 },
controls: { flexDirection: 'row', alignItems: 'center' },
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,163 @@
/**
* ShareTrackModal — share sheet + QR for co-listening (deep link, no backend)
* Co-listening: generates a deep link burmalda://listen?tid=123&ts=456
* Friend opens it → app seeks to same position and plays same track
*/
import { useState } from 'react';
import { View, Text, StyleSheet, Pressable, Modal, Alert } from 'react-native';
import * as Sharing from 'expo-sharing';
import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
import QRCode from 'react-native-qrcode-svg';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import Svg, { Path } from 'react-native-svg';
import { Colors, Font, Radius } from '../constants/theme';
import { Track, coverUrl } from '../services/api';
interface Props {
track: Track;
progress: number;
visible: boolean;
onClose: () => void;
}
function buildDeepLink(trackId: number, position: number) {
return `burmalda://listen?tid=${trackId}&ts=${Math.floor(position)}`;
}
export default function ShareTrackModal({ track, progress, visible, onClose }: Props) {
const [showQR, setShowQR] = useState(false);
const cover = coverUrl(track.cover_url);
const deepLink = buildDeepLink(track.id, progress);
const shareText = `${track.artist} — ${track.title}\n${deepLink}`;
const handleShare = async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
try {
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(deepLink, {
dialogTitle: `${track.artist} — ${track.title}`,
});
} else {
await Clipboard.setStringAsync(shareText);
Alert.alert('Скопировано', 'Ссылка скопирована в буфер');
}
} catch {}
};
const handleCopy = async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await Clipboard.setStringAsync(deepLink);
Alert.alert('Скопировано', 'Ссылка скопирована');
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={s.backdrop} onPress={onClose} />
<BlurView intensity={40} tint="dark" style={s.sheet}>
<View style={s.handle} />
{/* Track info */}
<View style={s.trackRow}>
{cover
? <Image source={{ uri: cover }} style={s.cover} contentFit="cover" />
: <View style={[s.cover, { backgroundColor: Colors.elevated }]} />
}
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={1}>{track.title}</Text>
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
</View>
</View>
{/* QR toggle */}
{showQR && (
<View style={s.qrWrap}>
<QRCode
value={deepLink}
size={180}
backgroundColor="transparent"
color={Colors.text}
/>
<Text style={s.qrHint}>Друг сканирует — сразу переходит к этому месту</Text>
</View>
)}
{/* Actions */}
<View style={s.actions}>
<ActionBtn
icon="share"
label="Поделиться"
onPress={handleShare}
/>
<ActionBtn
icon="copy"
label="Скопировать ссылку"
onPress={handleCopy}
/>
<ActionBtn
icon="qr"
label={showQR ? 'Скрыть QR' : 'QR-код'}
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setShowQR(v => !v); }}
active={showQR}
/>
</View>
<Text style={s.hint}>
Совместное прослушивание: друг откроет ссылку и приложение перейдёт к этому треку и позиции
</Text>
<Pressable style={s.closeBtn} onPress={onClose}>
<Text style={s.closeTxt}>Закрыть</Text>
</Pressable>
</BlurView>
</Modal>
);
}
function ActionBtn({ icon, label, onPress, active = false }: {
icon: 'share' | 'copy' | 'qr'; label: string; onPress: () => void; active?: boolean;
}) {
return (
<Pressable onPress={onPress} style={[s.actionBtn, active && s.actionBtnActive]}>
<View style={s.actionIcon}>
{icon === 'share' && (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13" stroke={active ? Colors.accent : Colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
)}
{icon === 'copy' && (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2M16 8h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-2" stroke={active ? Colors.accent : Colors.text} strokeWidth={2} strokeLinecap="round" />
</Svg>
)}
{icon === 'qr' && (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path d="M3 3h6v6H3zM15 3h6v6h-6zM3 15h6v6H3zM15 15h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM15 19h2v2h-2zM19 19h2v2h-2z" stroke={active ? Colors.accent : Colors.text} strokeWidth={1.8} strokeLinecap="round" />
</Svg>
)}
</View>
<Text style={[s.actionLabel, active && { color: Colors.accent }]}>{label}</Text>
</Pressable>
);
}
const s = StyleSheet.create({
backdrop: { flex: 1 },
sheet: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.08)' },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', alignSelf: 'center', marginBottom: 20 },
trackRow: { flexDirection: 'row', alignItems: 'center', gap: 14, marginBottom: 24 },
cover: { width: 52, height: 52, borderRadius: Radius.sm },
title: { color: Colors.text, fontSize: Font.md, fontWeight: '700' },
artist: { color: 'rgba(255,255,255,0.55)', fontSize: Font.sm, marginTop: 2 },
qrWrap: { alignItems: 'center', marginBottom: 24, padding: 20, backgroundColor: 'rgba(255,255,255,0.06)', borderRadius: 16 },
qrHint: { color: 'rgba(255,255,255,0.45)', fontSize: 12, marginTop: 12, textAlign: 'center' },
actions: { flexDirection: 'row', gap: 10, marginBottom: 16 },
actionBtn: { flex: 1, alignItems: 'center', gap: 8, backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14, paddingVertical: 14, paddingHorizontal: 8 },
actionBtnActive: { backgroundColor: `${Colors.accent}20`, borderWidth: 1, borderColor: `${Colors.accent}50` },
actionIcon: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
actionLabel: { color: Colors.text, fontSize: 11, fontWeight: '600', textAlign: 'center' },
hint: { color: 'rgba(255,255,255,0.3)', fontSize: 11, textAlign: 'center', lineHeight: 16, marginBottom: 20 },
closeBtn: { paddingVertical: 14, alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 14 },
closeTxt: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
});

View file

@ -0,0 +1,73 @@
/**
* SpectrumBars — animated equalizer bars for MiniPlayer / Player
*/
import { useEffect } from 'react';
import { View, StyleSheet } from 'react-native';
import Animated, {
useSharedValue, useAnimatedStyle, withRepeat, withSequence,
withTiming, Easing, cancelAnimation,
} from 'react-native-reanimated';
interface Props {
playing: boolean;
color?: string;
barCount?: number;
height?: number;
}
const DURATIONS = [420, 350, 500, 380, 460];
const HEIGHTS = [0.4, 0.7, 0.55, 0.85, 0.5];
function Bar({ playing, color, maxH, duration, minRatio }: {
playing: boolean; color: string; maxH: number; duration: number; minRatio: number;
}) {
const h = useSharedValue(maxH * minRatio);
useEffect(() => {
if (playing) {
h.value = withRepeat(
withSequence(
withTiming(maxH, { duration, easing: Easing.inOut(Easing.ease) }),
withTiming(maxH * 0.2, { duration, easing: Easing.inOut(Easing.ease) }),
),
-1,
true,
);
} else {
cancelAnimation(h);
h.value = withTiming(maxH * 0.25, { duration: 300 });
}
}, [playing]);
const style = useAnimatedStyle(() => ({
height: h.value,
backgroundColor: color,
borderRadius: 2,
width: 3,
}));
return <Animated.View style={style} />;
}
export default function SpectrumBars({ playing, color = '#ffffff', barCount = 4, height = 18 }: Props) {
const bars = Array.from({ length: barCount }, (_, i) => i);
return (
<View style={[s.wrap, { height }]}>
{bars.map((i) => (
<Bar
key={i}
playing={playing}
color={color}
maxH={height * (HEIGHTS[i % HEIGHTS.length])}
duration={DURATIONS[i % DURATIONS.length]}
minRatio={0.2}
/>
))}
</View>
);
}
const s = StyleSheet.create({
wrap: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 },
});

View file

@ -0,0 +1,81 @@
/**
* WaveAnimation — flowing wave bars used in "My Wave" header
* Simulates audio wave / signal motion with staggered sine animation
*/
import { useEffect } from 'react';
import { View, StyleSheet } from 'react-native';
import Animated, {
useSharedValue, useAnimatedStyle, withRepeat, withSequence,
withTiming, Easing, cancelAnimation,
} from 'react-native-reanimated';
import { Colors } from '../constants/theme';
interface Props {
active?: boolean;
barCount?: number;
height?: number;
color?: string;
}
const PHASES = [0, 0.4, 0.8, 0.2, 0.6, 0.1, 0.7, 0.3, 0.9, 0.5,
0.15, 0.55, 0.85, 0.25, 0.65];
const BASE_DURATION = 900;
function WaveBar({ active, maxH, phase, color }: {
active: boolean; maxH: number; phase: number; color: string;
}) {
const h = useSharedValue(maxH * 0.15);
useEffect(() => {
if (active) {
const delay = Math.round(phase * BASE_DURATION);
h.value = withSequence(
withTiming(maxH * 0.15, { duration: delay }),
withRepeat(
withSequence(
withTiming(maxH, { duration: BASE_DURATION, easing: Easing.inOut(Easing.sin) }),
withTiming(maxH * 0.1, { duration: BASE_DURATION, easing: Easing.inOut(Easing.sin) }),
),
-1,
false,
)
);
} else {
cancelAnimation(h);
h.value = withTiming(maxH * 0.12, { duration: 400 });
}
}, [active]);
const style = useAnimatedStyle(() => ({
height: h.value,
backgroundColor: color,
borderRadius: 3,
flex: 1,
marginHorizontal: 1.5,
}));
return <Animated.View style={style} />;
}
export default function WaveAnimation({ active = true, barCount = 40, height = 56, color }: Props) {
const bars = Array.from({ length: barCount }, (_, i) => i);
const barColor = color ?? Colors.accent;
return (
<View style={[s.wrap, { height }]}>
{bars.map((i) => (
<WaveBar
key={i}
active={active}
maxH={height}
phase={PHASES[i % PHASES.length]}
color={`${barColor}${Math.round(40 + (i % 5) * 24).toString(16).padStart(2, '0')}`}
/>
))}
</View>
);
}
const s = StyleSheet.create({
wrap: { flexDirection: 'row', alignItems: 'flex-end', overflow: 'hidden' },
});

View file

@ -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
View 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 };
}

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

141
main.yml
View file

@ -1,56 +1,85 @@
name: Build iOS IPA name: NodeShiftMusic iOS Build
on: on:
push: push:
branches: [main, master] branches: [main, master]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build-ipa:
runs-on: macos-latest name: Build Unsigned IPA
runs-on: macos-latest
steps:
- name: Checkout steps:
uses: actions/checkout@v4 - name: Checkout
uses: actions/checkout@v4
- name: Setup Node 20
uses: actions/setup-node@v4 - name: Setup Node 20
with: uses: actions/setup-node@v4
node-version: '20' with:
cache: 'npm' node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install - name: Install dependencies
run: npm ci
- name: Expo prebuild
run: npx expo prebuild --platform ios --clean - name: Install CocoaPods
env: run: sudo gem install cocoapods --no-document
EXPO_NO_DOTENV: 1
- name: Expo prebuild
- name: Build unsigned archive env:
run: | EXPO_NO_DOTENV: 1
xcodebuild archive \ run: npx expo prebuild --platform ios --clean
-workspace "ios/Burmalda Music.xcworkspace" \
-scheme "Burmalda Music" \ - name: Install Pods
-configuration Release \ working-directory: ios
-sdk iphoneos \ run: pod install --repo-update
-archivePath "$RUNNER_TEMP/burmalda.xcarchive" \
CODE_SIGNING_REQUIRED=NO \ - name: Find workspace & scheme
CODE_SIGN_IDENTITY="" \ id: xcode
CODE_SIGNING_ALLOWED=NO \ run: |
ENABLE_BITCODE=NO \ WS=$(find ios -name "*.xcworkspace" -maxdepth 2 | head -1)
| xcpretty || true SCHEME=$(basename "$WS" .xcworkspace)
echo "workspace=$WS" >> $GITHUB_OUTPUT
- name: Package IPA echo "scheme=$SCHEME" >> $GITHUB_OUTPUT
run: | echo "Workspace: $WS | Scheme: $SCHEME"
mkdir -p Payload
cp -r "$RUNNER_TEMP/burmalda.xcarchive/Products/Applications/"*.app Payload/ - name: Build unsigned archive
zip -qr burmalda-unsigned.ipa Payload run: |
rm -rf Payload xcodebuild archive \
-workspace "${{ steps.xcode.outputs.workspace }}" \
- name: Upload IPA artifact -scheme "${{ steps.xcode.outputs.scheme }}" \
uses: actions/upload-artifact@v4 -configuration Release \
with: -sdk iphoneos \
name: burmalda-unsigned-ipa -archivePath "$RUNNER_TEMP/NodeShiftMusic.xcarchive" \
path: burmalda-unsigned.ipa CODE_SIGNING_REQUIRED=NO \
retention-days: 30 CODE_SIGN_IDENTITY="" \
CODE_SIGNING_ALLOWED=NO \
ENABLE_BITCODE=NO \
2>&1 | xcpretty || true
- name: Package IPA
run: |
APPS="$RUNNER_TEMP/NodeShiftMusic.xcarchive/Products/Applications"
mkdir -p Payload
cp -r "$APPS/"*.app Payload/
zip -qr NodeShiftMusic-unsigned.ipa Payload
rm -rf Payload
echo "IPA size: $(du -sh NodeShiftMusic-unsigned.ipa | cut -f1)"
- name: Upload IPA
uses: actions/upload-artifact@v4
with:
name: NodeShiftMusic-unsigned
path: NodeShiftMusic-unsigned.ipa
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

File diff suppressed because it is too large Load diff

View file

@ -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
View 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
View 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!"

View file

@ -1,170 +1,259 @@
import { create } from 'zustand'; import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Track, User } from '../services/api'; import { Track, User } from '../services/api';
// ── Offline / Downloads ─────────────────────────────────────────────────────── // ── Offline / Downloads ───────────────────────────────────────────────────────
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; clearDownloading: (id: number) => void;
clearDownloading: (id: number) => void; loadDownloadedTracks: () => Promise<void>;
loadDownloadedTracks: () => Promise<void>; setOnline: (online: boolean) => void;
setOnline: (online: boolean) => void; }
}
export const useOffline = create<OfflineState>((set, get) => ({
export const useOffline = create<OfflineState>((set, get) => ({ downloadedTracks: new Set(),
downloadedTracks: new Set(), downloading: new Map(),
downloading: new Map(), isOnline: true,
isOnline: true,
addDownloadedTrack: (id) => {
addDownloadedTrack: (id) => { const next = new Set(get().downloadedTracks);
const next = new Set(get().downloadedTracks); next.add(id);
next.add(id); set({ downloadedTracks: next });
set({ downloadedTracks: next }); },
}, removeDownloadedTrack: (id) => {
const next = new Set(get().downloadedTracks);
removeDownloadedTrack: (id) => { next.delete(id);
const next = new Set(get().downloadedTracks); set({ downloadedTracks: next });
next.delete(id); },
set({ downloadedTracks: next }); setDownloading: (id, progress) => {
}, const next = new Map(get().downloading);
if (progress >= 1) next.delete(id);
setDownloading: (id, progress) => { else next.set(id, progress);
const next = new Map(get().downloading); set({ downloading: next });
if (progress >= 1) { },
next.delete(id); clearDownloading: (id) => {
} else { const next = new Map(get().downloading);
next.set(id, progress); next.delete(id);
} set({ downloading: next });
set({ downloading: next }); },
}, loadDownloadedTracks: async () => {
try {
clearDownloading: (id) => { const { getDownloadedTracks } = await import('../services/offline');
const next = new Map(get().downloading); const ids = await getDownloadedTracks();
next.delete(id); set({ downloadedTracks: new Set(ids) });
set({ downloading: next }); } catch (err) {
}, console.warn('Failed to load downloaded tracks:', err);
}
loadDownloadedTracks: async () => { },
try { setOnline: (online) => set({ isOnline: online }),
const { getDownloadedTracks } = await import('../services/offline'); }));
const ids = await getDownloadedTracks();
set({ downloadedTracks: new Set(ids) }); // ── Player ────────────────────────────────────────────────────────────────────
} catch (err) {
console.warn('Failed to load downloaded tracks:', err); interface PlayerState {
} queue: Track[];
}, index: number;
playing: boolean;
setOnline: (online) => set({ isOnline: online }), progress: number;
})); duration: number;
repeat: 0 | 1 | 2;
// ── Player ──────────────────────────────────────────────────────────────────── shuffle: boolean;
expanded: boolean;
interface PlayerState {
queue: Track[]; setQueue: (tracks: Track[], index?: number) => void;
index: number; setIndex: (i: number) => void;
playing: boolean; setPlaying: (v: boolean) => void;
progress: number; setProgress: (v: number) => void;
duration: number; setDuration: (v: number) => void;
repeat: 0 | 1 | 2; cycleRepeat: () => void;
shuffle: boolean; toggleShuffle: () => void;
expanded: boolean; setExpanded: (v: boolean) => void;
nextTrack: () => number;
setQueue: (tracks: Track[], index?: number) => void; prevTrack: () => number;
setIndex: (i: number) => void; }
setPlaying: (v: boolean) => void;
setProgress: (v: number) => void; export const usePlayer = create<PlayerState>((set, get) => ({
setDuration: (v: number) => void; queue: [],
cycleRepeat: () => void; index: -1,
toggleShuffle: () => void; playing: false,
setExpanded: (v: boolean) => void; progress: 0,
nextTrack: () => number; duration: 0,
prevTrack: () => number; repeat: 0,
} shuffle: false,
expanded: false,
export const usePlayer = create<PlayerState>((set, get) => ({
queue: [], setQueue: (tracks, index = 0) => set({ queue: tracks, index }),
index: -1, setIndex: (index) => set({ index }),
playing: false, setPlaying: (playing) => set({ playing }),
progress: 0, setProgress: (progress) => set({ progress }),
duration: 0, setDuration: (duration) => set({ duration }),
repeat: 0, cycleRepeat: () => set((s) => ({ repeat: ((s.repeat + 1) % 3) as 0 | 1 | 2 })),
shuffle: false, toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
expanded: false, setExpanded: (expanded) => set({ expanded }),
setQueue: (tracks, index = 0) => set({ queue: tracks, index }), nextTrack: () => {
setIndex: (index) => set({ index }), const { queue, index, shuffle, repeat } = get();
setPlaying: (playing) => set({ playing }), if (repeat === 2) return index;
setProgress: (progress) => set({ progress }), if (shuffle) return Math.floor(Math.random() * queue.length);
setDuration: (duration) => set({ duration }), const next = index + 1;
cycleRepeat: () => set((s) => ({ repeat: ((s.repeat + 1) % 3) as 0 | 1 | 2 })), if (next >= queue.length) return repeat === 1 ? 0 : index;
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })), return next;
setExpanded: (expanded) => set({ expanded }), },
prevTrack: () => {
nextTrack: () => { const { queue, index, shuffle } = get();
const { queue, index, shuffle, repeat } = get(); if (shuffle) return Math.floor(Math.random() * queue.length);
if (repeat === 2) return index; return index - 1 < 0 ? queue.length - 1 : index - 1;
if (shuffle) return Math.floor(Math.random() * queue.length); },
const next = index + 1; }));
if (next >= queue.length) return repeat === 1 ? 0 : index;
return next; // ── Auth ──────────────────────────────────────────────────────────────────────
},
prevTrack: () => { interface AuthState {
const { queue, index, shuffle } = get(); user: User | null;
if (shuffle) return Math.floor(Math.random() * queue.length); ready: boolean;
return index - 1 < 0 ? queue.length - 1 : index - 1; setUser: (u: User | null) => void;
}, setReady: (v: boolean) => void;
})); }
// ── Auth ────────────────────────────────────────────────────────────────────── export const useAuth = create<AuthState>((set) => ({
user: null,
interface AuthState { ready: false,
user: User | null; setUser: (user) => set({ user }),
ready: boolean; setReady: (ready) => set({ ready }),
setUser: (u: User | null) => void; }));
setReady: (v: boolean) => void;
} // ── Likes ─────────────────────────────────────────────────────────────────────
export const useAuth = create<AuthState>((set) => ({ const LIKES_KEY = 'ns-music-likes';
user: null,
ready: false, interface LikesState {
setUser: (user) => set({ user }), likes: Set<number>;
setReady: (ready) => set({ ready }), loadLikes: () => Promise<void>;
})); toggleLike: (id: number) => void;
isLiked: (id: number) => boolean;
// ── Likes ───────────────────────────────────────────────────────────────────── }
const LIKES_KEY = 'ns-music-likes'; export const useLikes = create<LikesState>((set, get) => ({
likes: new Set(),
interface LikesState {
likes: Set<number>; loadLikes: async () => {
loadLikes: () => Promise<void>; try {
toggleLike: (id: number) => void; const raw = await AsyncStorage.getItem(LIKES_KEY);
isLiked: (id: number) => boolean; const arr: number[] = raw ? JSON.parse(raw) : [];
} set({ likes: new Set(arr) });
} catch {}
export const useLikes = create<LikesState>((set, get) => ({ },
likes: new Set(), toggleLike: (id) => {
const next = new Set(get().likes);
loadLikes: async () => { if (next.has(id)) next.delete(id); else next.add(id);
try { set({ likes: next });
const raw = await AsyncStorage.getItem(LIKES_KEY); AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {});
const arr: number[] = raw ? JSON.parse(raw) : []; },
set({ likes: new Set(arr) }); isLiked: (id) => get().likes.has(id),
} catch {} }));
},
// ── Hidden Tracks ─────────────────────────────────────────────────────────────
toggleLike: (id) => {
const next = new Set(get().likes); const HIDDEN_KEY = 'ns-music-hidden';
if (next.has(id)) next.delete(id); else next.add(id);
set({ likes: next }); interface HiddenState {
AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...next])).catch(() => {}); hidden: Set<number>;
}, loadHidden: () => Promise<void>;
toggleHide: (id: number) => void;
isLiked: (id) => get().likes.has(id), 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(() => {});
},
}));

View file

@ -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
View 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;
}