Add files via upload
This commit is contained in:
parent
9fb037a648
commit
2a8ba7eeba
14 changed files with 478 additions and 387 deletions
1
app.json
1
app.json
|
|
@ -29,6 +29,7 @@
|
||||||
"scheme": "burmalda",
|
"scheme": "burmalda",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
"react-native-track-player",
|
||||||
[
|
[
|
||||||
"expo-updates",
|
"expo-updates",
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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 } from '../../store';
|
||||||
|
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';
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ export default function TabsLayout() {
|
||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
const { expanded } = usePlayer();
|
const { expanded } = usePlayer();
|
||||||
const { loadLikes } = useLikes();
|
const { loadLikes } = useLikes();
|
||||||
|
useAudio(); // single audio controller for the whole app
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ready && !user) router.replace('/auth');
|
if (ready && !user) router.replace('/auth');
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { useEffect, useState, useCallback } from 'react';
|
||||||
import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl } from 'react-native';
|
import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl } from 'react-native';
|
||||||
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
import Animated, { FadeIn, 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 Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } 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, getAlbums, Track, Album, coverUrl } from '../../services/api';
|
import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
|
||||||
import { usePlayer, useLikes } from '../../store';
|
import { usePlayer, useLikes } from '../../store';
|
||||||
|
import CoverImage from '../../components/CoverImage';
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -47,27 +47,30 @@ export default function HomeScreen() {
|
||||||
refreshControl={<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.text2} />}
|
refreshControl={<RefreshControl refreshing={refresh} onRefresh={onRefresh} tintColor={Colors.text2} />}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<Animated.View entering={FadeIn.duration(400)} style={styles.pageHeader}>
|
<Animated.View entering={FadeIn.duration(400)} style={styles.pageHeader}>
|
||||||
<Text style={styles.pageTitle}>NodeShift Music</Text>
|
<Text style={styles.pageTitle}>NodeShift Music</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Now playing strip (if track exists) */}
|
|
||||||
<NowPlayingStrip />
|
<NowPlayingStrip />
|
||||||
|
|
||||||
{/* Albums */}
|
|
||||||
{albums.length > 0 && (
|
{albums.length > 0 && (
|
||||||
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
<Animated.View entering={FadeInDown.delay(100)} style={styles.section}>
|
||||||
<Text style={styles.secTitle}>Альбомы</Text>
|
<Text style={styles.secTitle}>Альбомы</Text>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hRow}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hRow}>
|
||||||
{albums.map((a, i) => (
|
{albums.map((a, i) => (
|
||||||
<AlbumCard key={a.id} album={a} delay={i * 50} />
|
<AlbumCard key={a.id} album={a} delay={i * 50} onPress={() => {
|
||||||
|
const albumTracks = tracks.filter(t => t.album === a.title);
|
||||||
|
if (albumTracks.length > 0) {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
setQueue(albumTracks, 0);
|
||||||
|
usePlayer.getState().setExpanded(true);
|
||||||
|
}
|
||||||
|
}} />
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tracks */}
|
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
|
<Animated.View entering={FadeInDown.delay(200)} style={styles.section}>
|
||||||
<Text style={styles.secTitle}>Треки</Text>
|
<Text style={styles.secTitle}>Треки</Text>
|
||||||
|
|
@ -92,15 +95,21 @@ function NowPlayingStrip() {
|
||||||
const track = queue[index];
|
const track = queue[index];
|
||||||
if (!track) return null;
|
if (!track) return null;
|
||||||
const cover = coverUrl(track.cover_url);
|
const cover = coverUrl(track.cover_url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInDown.duration(300)} style={styles.npStrip}>
|
<Animated.View entering={FadeInDown.duration(300)} style={styles.npStrip}>
|
||||||
{cover && <Image source={{ uri: cover }} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={40} />}
|
{/* Blurred cover background */}
|
||||||
|
<CoverImage
|
||||||
|
uri={cover}
|
||||||
|
title={track.title}
|
||||||
|
size={160}
|
||||||
|
radius={0}
|
||||||
|
blurRadius={40}
|
||||||
|
style={StyleSheet.absoluteFill as any}
|
||||||
|
/>
|
||||||
<View style={[StyleSheet.absoluteFill, styles.npDim]} />
|
<View style={[StyleSheet.absoluteFill, styles.npDim]} />
|
||||||
<Pressable style={styles.npContent} onPress={() => setExpanded(true)}>
|
<Pressable style={styles.npContent} onPress={() => setExpanded(true)}>
|
||||||
{cover
|
<CoverImage uri={cover} title={track.title} size={52} radius={Radius.sm} />
|
||||||
? <Image source={{ uri: cover }} style={styles.npCover} contentFit="cover" />
|
|
||||||
: <View style={[styles.npCover, { backgroundColor: Colors.elevated }]} />
|
|
||||||
}
|
|
||||||
<View style={styles.npInfo}>
|
<View style={styles.npInfo}>
|
||||||
<Text style={styles.npLabel}>Сейчас играет</Text>
|
<Text style={styles.npLabel}>Сейчас играет</Text>
|
||||||
<Text style={styles.npTitle} numberOfLines={1}>{track.title}</Text>
|
<Text style={styles.npTitle} numberOfLines={1}>{track.title}</Text>
|
||||||
|
|
@ -108,7 +117,7 @@ function NowPlayingStrip() {
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.npDot}>
|
<View style={styles.npDot}>
|
||||||
{playing
|
{playing
|
||||||
? <View style={styles.npEq}>{[1,2,3].map(i => <Animated.View key={i} style={[styles.npBar, { height: 4 + i * 4 }]} />)}</View>
|
? <View style={styles.npEq}>{[1, 2, 3].map(i => <Animated.View key={i} style={[styles.npBar, { height: 4 + i * 4 }]} />)}</View>
|
||||||
: <Svg width={16} height={16} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>
|
: <Svg width={16} height={16} viewBox="0 0 24 24" fill={Colors.text}><Path d="M8 5v14l11-7z" /></Svg>
|
||||||
}
|
}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -117,25 +126,23 @@ function NowPlayingStrip() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlbumCard({ album, delay }: { album: Album; delay: number }) {
|
function AlbumCard({ album, delay, onPress }: { album: Album; delay: number; onPress: () => void }) {
|
||||||
const scale = useSharedValue(1);
|
const scale = useSharedValue(1);
|
||||||
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||||
const cover = coverUrl(album.cover_url);
|
const cover = coverUrl(album.cover_url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInDown.delay(delay)} style={style}>
|
<Animated.View entering={FadeInDown.delay(delay)} style={style}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
|
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
|
||||||
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
|
||||||
|
onPress={onPress}
|
||||||
style={styles.albumCard}
|
style={styles.albumCard}
|
||||||
>
|
>
|
||||||
<Image
|
<CoverImage uri={cover} title={album.title} size={CARD_W} radius={Radius.md} style={{ marginBottom: Spacing.sm }} />
|
||||||
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
|
||||||
style={styles.albumCover}
|
|
||||||
contentFit="cover"
|
|
||||||
transition={300}
|
|
||||||
/>
|
|
||||||
<Text style={styles.albumTitle} numberOfLines={1}>{album.title}</Text>
|
<Text style={styles.albumTitle} numberOfLines={1}>{album.title}</Text>
|
||||||
<Text style={styles.albumArtist} numberOfLines={1}>{album.artist}</Text>
|
<Text style={styles.albumArtist} numberOfLines={1}>{album.artist}</Text>
|
||||||
|
<Text style={styles.albumCount}>{album.track_count} треков</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|
@ -151,12 +158,7 @@ function TrackRow({ track, index, liked, onPress, onLike }: {
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
|
<Pressable onPress={onPress} style={[styles.trackRow, active && styles.trackRowActive]}>
|
||||||
<Text style={styles.trackNum}>{index + 1}</Text>
|
<Text style={styles.trackNum}>{index + 1}</Text>
|
||||||
<Image
|
<CoverImage uri={cover} title={track.title} size={44} radius={Radius.sm} />
|
||||||
source={cover ? { uri: cover } : require('../../assets/placeholder.png')}
|
|
||||||
style={styles.trackCover}
|
|
||||||
contentFit="cover"
|
|
||||||
transition={200}
|
|
||||||
/>
|
|
||||||
<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>
|
<Text style={styles.trackArtist} numberOfLines={1}>{track.artist}</Text>
|
||||||
|
|
@ -185,22 +187,20 @@ const styles = StyleSheet.create({
|
||||||
secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
|
secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
|
||||||
hRow: { gap: Spacing.md, paddingRight: Spacing.md },
|
hRow: { gap: Spacing.md, paddingRight: Spacing.md },
|
||||||
albumCard: { width: CARD_W },
|
albumCard: { width: CARD_W },
|
||||||
albumCover: { width: CARD_W, height: CARD_W, borderRadius: Radius.md, backgroundColor: Colors.elevated, marginBottom: Spacing.sm },
|
|
||||||
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
|
||||||
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
|
||||||
|
albumCount: { color: Colors.text3, fontSize: Font.xs, marginTop: 2 },
|
||||||
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: Spacing.sm },
|
trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: Spacing.sm },
|
||||||
trackRowActive: { backgroundColor: Colors.surface },
|
trackRowActive: { backgroundColor: Colors.surface },
|
||||||
trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
|
||||||
trackCover: { width: 44, height: 44, borderRadius: Radius.sm, backgroundColor: Colors.elevated },
|
|
||||||
trackInfo: { flex: 1 },
|
trackInfo: { flex: 1 },
|
||||||
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 },
|
trackDur: { color: Colors.text3, fontSize: Font.xs },
|
||||||
// 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.55)' },
|
npDim: { backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||||
npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
||||||
npCover: { width: 52, height: 52, borderRadius: Radius.sm },
|
|
||||||
npInfo: { flex: 1 },
|
npInfo: { flex: 1 },
|
||||||
npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
|
npLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
|
||||||
npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
npTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
|
||||||
|
|
|
||||||
26
app/auth.tsx
26
app/auth.tsx
|
|
@ -14,6 +14,7 @@ import Svg, { Path, Circle, Polyline, Line } from 'react-native-svg';
|
||||||
import { Colors, Font, Radius, Spacing } from '../constants/theme';
|
import { Colors, Font, Radius, Spacing } from '../constants/theme';
|
||||||
import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api';
|
import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api';
|
||||||
import { useAuth } from '../store';
|
import { useAuth } from '../store';
|
||||||
|
import { saveUser } from '../hooks/useAuth';
|
||||||
|
|
||||||
WebBrowser.maybeCompleteAuthSession();
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ export default function AuthScreen() {
|
||||||
}
|
}
|
||||||
const data = await exchangeCode(code);
|
const data = await exchangeCode(code);
|
||||||
await saveTokens(data.access_token, data.refresh_token);
|
await saveTokens(data.access_token, data.refresh_token);
|
||||||
|
await saveUser(data.user);
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
router.replace('/(tabs)');
|
router.replace('/(tabs)');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -60,13 +62,13 @@ export default function AuthScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Blob color="#1db954" size={420} left={-80} top={-100} delay={0} />
|
<Blob color={Colors.accent} size={420} left={-80} top={-100} delay={0} />
|
||||||
<Blob color="#6c63ff" size={320} left={width - 200} top={height * 0.55} delay={2000} />
|
<Blob color="#6c63ff" size={320} left={width - 200} top={height * 0.55} delay={2000} />
|
||||||
<Blob color="#1db954" size={240} left={width * 0.3} top={height - 100} delay={4000} />
|
<Blob color={Colors.accent} size={240} left={width * 0.3} top={height - 100} delay={4000} />
|
||||||
|
|
||||||
<Animated.View style={styles.card} entering={FadeIn.duration(600)}>
|
<Animated.View style={styles.card} entering={FadeIn.duration(600)}>
|
||||||
<Animated.View entering={SlideInDown.delay(100).springify()} style={styles.logoWrap}>
|
<Animated.View entering={SlideInDown.delay(100).springify()} style={styles.logoWrap}>
|
||||||
<LinearGradient colors={['#1db954', '#168f3f']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.logoIcon}>
|
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.logoIcon}>
|
||||||
<Svg width={38} height={38} viewBox="0 0 24 24" fill="none">
|
<Svg width={38} height={38} viewBox="0 0 24 24" fill="none">
|
||||||
<Path d="M9 18V5l12-2v13" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
<Path d="M9 18V5l12-2v13" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||||
<Circle cx={6} cy={18} r={3} fill="#fff" />
|
<Circle cx={6} cy={18} r={3} fill="#fff" />
|
||||||
|
|
@ -86,14 +88,14 @@ export default function AuthScreen() {
|
||||||
onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }}
|
onPressOut={() => { btnScale.value = withSpring(1, { damping: 14 }); }}
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
>
|
>
|
||||||
<LinearGradient colors={['#1db954', '#17a349']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.btn}>
|
<LinearGradient colors={[Colors.accent, '#2a5cc4']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.btn}>
|
||||||
{loading
|
{loading
|
||||||
? <ActivityIndicator color="#000" size="small" />
|
? <ActivityIndicator color="#fff" size="small" />
|
||||||
: (
|
: (
|
||||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
|
||||||
<Path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" stroke="#000" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
<Path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<Polyline points="8 17 12 13 16 17" stroke="#000" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
<Polyline points="8 17 12 13 16 17" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<Line x1="12" y1="13" x2="12" y2="21" stroke="#000" strokeWidth={2} strokeLinecap="round" />
|
<Line x1="12" y1="13" x2="12" y2="21" stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.btnText}>
|
<Text style={styles.btnText}>
|
||||||
|
|
@ -149,15 +151,15 @@ function Blob({ color, size, left, top, delay }: {
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
container: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
||||||
blob: { position: 'absolute', opacity: 0.15 },
|
blob: { position: 'absolute', opacity: 0.12 },
|
||||||
card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 },
|
card: { width: '100%', maxWidth: 360, alignItems: 'center', paddingHorizontal: Spacing.xl, zIndex: 10 },
|
||||||
logoWrap: { alignItems: 'center', marginBottom: 40 },
|
logoWrap: { alignItems: 'center', marginBottom: 40 },
|
||||||
logoIcon: { width: 84, height: 84, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.md, shadowColor: '#1db954', shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 },
|
logoIcon: { width: 84, height: 84, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.md, shadowColor: Colors.accent, shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.45, shadowRadius: 22 },
|
||||||
logoTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: 4 },
|
logoTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5, marginBottom: 4 },
|
||||||
logoSub: { color: Colors.text3, fontSize: Font.sm, fontWeight: '500' },
|
logoSub: { color: Colors.text3, fontSize: Font.sm, fontWeight: '500' },
|
||||||
btnWrap: { width: '100%', borderRadius: Radius.md, overflow: 'hidden', shadowColor: '#1db954', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 },
|
btnWrap: { width: '100%', borderRadius: Radius.md, overflow: 'hidden', shadowColor: Colors.accent, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 18 },
|
||||||
btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 },
|
btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, paddingVertical: 16, paddingHorizontal: 20 },
|
||||||
btnText: { color: '#000', fontSize: Font.md, fontWeight: '700' },
|
btnText: { color: '#fff', fontSize: Font.md, fontWeight: '700' },
|
||||||
error: { color: Colors.red, fontSize: Font.sm, textAlign: 'center', marginTop: Spacing.sm },
|
error: { color: Colors.red, fontSize: Font.sm, textAlign: 'center', marginTop: Spacing.sm },
|
||||||
footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 },
|
footer: { marginTop: Spacing.xl, color: Colors.text3, fontSize: Font.xs, textAlign: 'center', lineHeight: 18 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
73
components/CoverImage.tsx
Normal file
73
components/CoverImage.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Text, ViewStyle } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Radius } from '../constants/theme';
|
||||||
|
|
||||||
|
// 12 distinct dark gradients — one is assigned per title hash
|
||||||
|
const GRADIENTS: [string, string][] = [
|
||||||
|
['#12234a', '#1e4080'],
|
||||||
|
['#1a0e3a', '#3d1a7a'],
|
||||||
|
['#0e2a3a', '#1a5a7a'],
|
||||||
|
['#2a0e1e', '#6a1a3e'],
|
||||||
|
['#0e2a1e', '#1a6a3e'],
|
||||||
|
['#2a1e0e', '#7a4a1e'],
|
||||||
|
['#1e0e2a', '#4a1e6a'],
|
||||||
|
['#0e1e2a', '#1e4a6a'],
|
||||||
|
['#2a0e2a', '#6a1a6a'],
|
||||||
|
['#1e2a0e', '#4a6a1e'],
|
||||||
|
['#2a1e1e', '#6a3a3a'],
|
||||||
|
['#0e2a2a', '#1a6a6a'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function textHash(s: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (const c of s) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
uri: string | null;
|
||||||
|
title: string;
|
||||||
|
size: number;
|
||||||
|
radius?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
blurRadius?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CoverImage({ uri, title, size, radius = Radius.sm, style, blurRadius }: Props) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
// Reset error state when URI changes (new track loaded)
|
||||||
|
useEffect(() => { setFailed(false); }, [uri]);
|
||||||
|
|
||||||
|
if (uri && !failed) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={[{ width: size, height: size, borderRadius: radius, backgroundColor: '#1a1a1a' }, style]}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={250}
|
||||||
|
blurRadius={blurRadius}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [c1, c2] = GRADIENTS[textHash(title || '') % GRADIENTS.length];
|
||||||
|
const letter = (title || '?').trim().toUpperCase()[0];
|
||||||
|
const fontSize = size * 0.36;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinearGradient
|
||||||
|
colors={[c1, c2]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={[{ width: size, height: size, borderRadius: radius, alignItems: 'center', justifyContent: 'center' }, style]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize, fontWeight: '800', textAlign: 'center' }}>
|
||||||
|
{letter}
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,15 +8,14 @@ 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, Rect } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
import { Colors, Font, Radius, 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 { useAudio } from '../hooks/useAudio';
|
import { togglePlay, seekTo } from '../hooks/useAudio';
|
||||||
import { coverUrl } from '../services/api';
|
import { coverUrl } from '../services/api';
|
||||||
|
|
||||||
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();
|
||||||
const { togglePlay } = useAudio();
|
|
||||||
const { toggleLike, isLiked } = useLikes();
|
const { toggleLike, isLiked } = useLikes();
|
||||||
const track = queue[index];
|
const track = queue[index];
|
||||||
|
|
||||||
|
|
@ -47,11 +46,6 @@ export default function MiniPlayer() {
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
}, [setExpanded]);
|
}, [setExpanded]);
|
||||||
|
|
||||||
const onPlayPress = useCallback(() => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
togglePlay();
|
|
||||||
}, [togglePlay]);
|
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -77,80 +71,41 @@ export default function MiniPlayer() {
|
||||||
entering={FadeInDown.springify()}
|
entering={FadeInDown.springify()}
|
||||||
exiting={FadeOutDown.springify()}
|
exiting={FadeOutDown.springify()}
|
||||||
>
|
>
|
||||||
{/* Glass background */}
|
|
||||||
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
|
<BlurView intensity={30} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
<View style={[StyleSheet.absoluteFill, s.bgTint]} />
|
<View style={[StyleSheet.absoluteFill, s.bgTint]} />
|
||||||
|
|
||||||
{/* Progress line at top */}
|
{/* Progress line */}
|
||||||
<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>
|
||||||
|
|
||||||
{/* Content row */}
|
|
||||||
<Pressable style={s.row} onPress={openPlayer} android_ripple={null}>
|
<Pressable style={s.row} onPress={openPlayer} android_ripple={null}>
|
||||||
{/* Cover */}
|
|
||||||
<Image
|
<Image
|
||||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
||||||
style={s.cover}
|
style={s.cover}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
transition={200}
|
transition={200}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<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>
|
||||||
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<View style={s.controls}>
|
<View style={s.controls}>
|
||||||
{/* Like */}
|
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={10} style={s.ctlBtn}>
|
||||||
<Pressable
|
|
||||||
onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }}
|
|
||||||
hitSlop={10}
|
|
||||||
style={s.ctlBtn}
|
|
||||||
>
|
|
||||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
||||||
<Path
|
<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" />
|
||||||
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
|
|
||||||
stroke={liked ? Colors.pink : Colors.text2}
|
|
||||||
strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</Svg>
|
</Svg>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<Pressable onPress={(e) => { e.stopPropagation(); onPrev(); }} hitSlop={10} style={s.ctlBtn}>
|
||||||
{/* Prev */}
|
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></Svg>
|
||||||
<Pressable
|
|
||||||
onPress={(e) => { e.stopPropagation(); onPrev(); }}
|
|
||||||
hitSlop={10}
|
|
||||||
style={s.ctlBtn}
|
|
||||||
>
|
|
||||||
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24">
|
|
||||||
<Path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
|
|
||||||
</Svg>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<Pressable onPress={(e) => { e.stopPropagation(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); togglePlay(); }} hitSlop={10} style={s.ctlBtn}>
|
||||||
{/* Play/Pause */}
|
|
||||||
<Pressable
|
|
||||||
onPress={(e) => { e.stopPropagation(); onPlayPress(); }}
|
|
||||||
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={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>
|
: <Svg width={18} height={18} 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}>
|
||||||
{/* Next */}
|
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24"><Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" /></Svg>
|
||||||
<Pressable
|
|
||||||
onPress={(e) => { e.stopPropagation(); onNext(); }}
|
|
||||||
hitSlop={10}
|
|
||||||
style={s.ctlBtn}
|
|
||||||
>
|
|
||||||
<Svg width={18} height={18} fill={Colors.text} viewBox="0 0 24 24">
|
|
||||||
<Path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" />
|
|
||||||
</Svg>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
@ -160,7 +115,7 @@ 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', borderRadius: '14px 14px 0 0' as any, borderTopLeftRadius: 14, borderTopRightRadius: 14 },
|
wrap: { position: 'absolute', left: 0, right: 0, height: MINI_PLAYER_HEIGHT, overflow: 'hidden', borderTopLeftRadius: 14, borderTopRightRadius: 14 },
|
||||||
bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' },
|
bgTint: { backgroundColor: 'rgba(10,10,10,0.4)' },
|
||||||
progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 },
|
progressBg: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: 'rgba(255,255,255,0.07)', zIndex: 2 },
|
||||||
progressFill:{ height: 2, backgroundColor: Colors.accent },
|
progressFill:{ height: 2, backgroundColor: Colors.accent },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import { View, Text, StyleSheet, Pressable, Dimensions, ScrollView } from 'react-native';
|
||||||
View, Text, StyleSheet, Pressable, Dimensions, ScrollView,
|
|
||||||
} from 'react-native';
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue, useAnimatedStyle, withSpring,
|
useSharedValue, useAnimatedStyle, withSpring,
|
||||||
useAnimatedGestureHandler, runOnJS, FadeIn, interpolate,
|
useAnimatedGestureHandler, runOnJS, FadeIn, interpolate,
|
||||||
|
|
@ -13,9 +11,9 @@ 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 } from 'react-native-svg';
|
import Svg, { Path, Polyline, Line, Rect } from 'react-native-svg';
|
||||||
import { Colors, Font, Radius, Spacing } from '../constants/theme';
|
import { Colors, Font, Radius } from '../constants/theme';
|
||||||
import { usePlayer, useLikes } from '../store';
|
import { usePlayer, useLikes } from '../store';
|
||||||
import { useAudio } 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';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
@ -26,7 +24,6 @@ export default function Player() {
|
||||||
queue, index, playing, progress, duration,
|
queue, index, playing, progress, duration,
|
||||||
repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle,
|
repeat, shuffle, setExpanded, cycleRepeat, toggleShuffle,
|
||||||
} = usePlayer();
|
} = usePlayer();
|
||||||
const { togglePlay, seek } = useAudio();
|
|
||||||
const { toggleLike, isLiked } = useLikes();
|
const { toggleLike, isLiked } = useLikes();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const track = queue[index];
|
const track = queue[index];
|
||||||
|
|
@ -40,16 +37,13 @@ export default function Player() {
|
||||||
|
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
const coverScale = useSharedValue(1);
|
const coverScale = useSharedValue(1);
|
||||||
const lyrSheetY = useSharedValue(500);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 });
|
coverScale.value = withSpring(playing ? 1 : 0.88, { damping: 16, stiffness: 120 });
|
||||||
}, [playing]);
|
}, [playing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLrcLines([]);
|
setLrcLines([]); setPlain(null); setLyricsOpen(false);
|
||||||
setPlain(null);
|
|
||||||
setLyricsOpen(false);
|
|
||||||
}, [track?.id]);
|
}, [track?.id]);
|
||||||
|
|
||||||
const loadLyrics = useCallback(async () => {
|
const loadLyrics = useCallback(async () => {
|
||||||
|
|
@ -63,37 +57,28 @@ export default function Player() {
|
||||||
setLyricsOpen(true);
|
setLyricsOpen(true);
|
||||||
}, [track, lrcLines.length, plainLyrics]);
|
}, [track, lrcLines.length, plainLyrics]);
|
||||||
|
|
||||||
// Auto-scroll lyrics to current line
|
|
||||||
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 (!lyricsOpen || currentLineIdx < 0) return;
|
||||||
const y = lineHeights.current.slice(0, currentLineIdx).reduce((s, h) => s + h, 0);
|
const y = lineHeights.current.slice(0, currentLineIdx).reduce((sum, h) => sum + h, 0);
|
||||||
lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 120), animated: true });
|
lyricsScrollRef.current?.scrollTo({ y: Math.max(0, y - 120), animated: true });
|
||||||
}, [currentLineIdx, lyricsOpen]);
|
}, [currentLineIdx, lyricsOpen]);
|
||||||
|
|
||||||
const gestureHandler = useAnimatedGestureHandler<any, { startY: number }>({
|
const gestureHandler = useAnimatedGestureHandler<any, { startY: number }>({
|
||||||
onStart: (_, ctx) => { ctx.startY = translateY.value; },
|
onStart: (_, ctx) => { ctx.startY = translateY.value; },
|
||||||
onActive: (e, ctx) => {
|
onActive: (e, ctx) => { if (e.translationY > 0) translateY.value = ctx.startY + e.translationY; },
|
||||||
if (e.translationY > 0) translateY.value = ctx.startY + e.translationY;
|
|
||||||
},
|
|
||||||
onEnd: (e) => {
|
onEnd: (e) => {
|
||||||
if (e.translationY > 100 || e.velocityY > 700) runOnJS(close)();
|
if (e.translationY > 100 || e.velocityY > 700) runOnJS(close)();
|
||||||
else translateY.value = withSpring(0, { damping: 20 });
|
else translateY.value = withSpring(0, { damping: 20 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerStyle = useAnimatedStyle(() => ({
|
const containerStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }] }));
|
||||||
transform: [{ translateY: translateY.value }],
|
const overlayOpacity = useAnimatedStyle(() => ({ opacity: interpolate(translateY.value, [0, 300], [1, 0]) }));
|
||||||
}));
|
const coverStyle = useAnimatedStyle(() => ({ transform: [{ scale: coverScale.value }] }));
|
||||||
const overlayOpacity = useAnimatedStyle(() => ({
|
|
||||||
opacity: interpolate(translateY.value, [0, 300], [1, 0]),
|
|
||||||
}));
|
|
||||||
const coverStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ scale: coverScale.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
@ -108,15 +93,10 @@ export default function Player() {
|
||||||
|
|
||||||
const onPrev = useCallback(() => {
|
const onPrev = useCallback(() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
if (progress > 3) { seek(0); return; }
|
if (progress > 3) { seekTo(0); return; }
|
||||||
const pi = usePlayer.getState().prevTrack();
|
const pi = usePlayer.getState().prevTrack();
|
||||||
usePlayer.getState().setIndex(pi);
|
usePlayer.getState().setIndex(pi);
|
||||||
}, [progress, seek]);
|
}, [progress]);
|
||||||
|
|
||||||
const onPlay = useCallback(() => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
||||||
togglePlay();
|
|
||||||
}, [togglePlay]);
|
|
||||||
|
|
||||||
if (!track) return null;
|
if (!track) return null;
|
||||||
|
|
||||||
|
|
@ -126,77 +106,47 @@ export default function Player() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}>
|
<Animated.View style={[StyleSheet.absoluteFill, s.root, overlayOpacity]} entering={FadeIn.duration(200)}>
|
||||||
{/* Blurred album art background */}
|
{/* Blurred background */}
|
||||||
<Image
|
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={80} />
|
||||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
|
||||||
style={StyleSheet.absoluteFill}
|
|
||||||
contentFit="cover"
|
|
||||||
blurRadius={80}
|
|
||||||
/>
|
|
||||||
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
|
<View style={[StyleSheet.absoluteFill, s.bgDim]} />
|
||||||
<BlurView intensity={20} tint="dark" style={StyleSheet.absoluteFill} />
|
<BlurView intensity={20} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
|
||||||
<PanGestureHandler onGestureEvent={gestureHandler}>
|
<PanGestureHandler onGestureEvent={gestureHandler}>
|
||||||
<Animated.View style={[s.sheet, containerStyle, {
|
<Animated.View style={[s.sheet, containerStyle, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 16 }]}>
|
||||||
paddingTop: insets.top + 8,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}]}>
|
|
||||||
|
|
||||||
{/* Drag handle + top label */}
|
{/* Top bar */}
|
||||||
<Pressable onPress={close} hitSlop={20} style={s.topBar}>
|
<View style={s.topBar}>
|
||||||
<View style={s.handle} />
|
|
||||||
<Text style={s.topLabel}>Сейчас играет</Text>
|
<Text style={s.topLabel}>Сейчас играет</Text>
|
||||||
<Pressable onPress={close} hitSlop={16} style={s.closeBtn}>
|
<Pressable onPress={close} hitSlop={16} style={s.closeBtn}>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none">
|
<Svg width={14} height={14} viewBox="0 0 24 24" fill="none">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke="rgba(255,255,255,0.5)" strokeWidth={2} strokeLinecap="round" />
|
<Path d="M18 6L6 18M6 6l12 12" stroke="rgba(255,255,255,0.5)" strokeWidth={2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</View>
|
||||||
|
|
||||||
{/* Cover */}
|
{/* Cover */}
|
||||||
<View style={s.coverWrap}>
|
<View style={s.coverWrap}>
|
||||||
<Animated.View style={[s.coverShadow, coverStyle]}>
|
<Animated.View style={[s.coverShadow, coverStyle]}>
|
||||||
<Image
|
<Image source={cover ? { uri: cover } : require('../assets/placeholder.png')} style={s.cover} contentFit="cover" transition={300} />
|
||||||
source={cover ? { uri: cover } : require('../assets/placeholder.png')}
|
|
||||||
style={s.cover}
|
|
||||||
contentFit="cover"
|
|
||||||
transition={300}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Meta row */}
|
{/* Meta */}
|
||||||
<View style={s.metaRow}>
|
<View style={s.metaRow}>
|
||||||
<View style={s.metaText}>
|
<View style={s.metaText}>
|
||||||
<Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text>
|
<Text style={s.trackTitle} numberOfLines={1}>{track.title}</Text>
|
||||||
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
<Text style={s.artist} numberOfLines={1}>{track.artist}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={12}>
|
||||||
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }}
|
|
||||||
hitSlop={12}
|
|
||||||
>
|
|
||||||
<Svg width={22} height={22} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
<Svg width={22} height={22} viewBox="0 0 24 24" fill={liked ? Colors.pink : 'none'}>
|
||||||
<Path
|
<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.4)'} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
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.4)'}
|
|
||||||
strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</Svg>
|
</Svg>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Seek bar */}
|
{/* Seek */}
|
||||||
<View style={s.seekWrap}>
|
<View style={s.seekWrap}>
|
||||||
<Slider
|
<Slider style={{ width: '100%', height: 32 }} minimumValue={0} maximumValue={1} value={pct} minimumTrackTintColor={Colors.text} maximumTrackTintColor="rgba(255,255,255,0.2)" thumbTintColor={Colors.text} onSlidingComplete={(v) => seekTo(v * duration)} />
|
||||||
style={{ width: '100%', height: 32 }}
|
|
||||||
minimumValue={0}
|
|
||||||
maximumValue={1}
|
|
||||||
value={pct}
|
|
||||||
minimumTrackTintColor={Colors.text}
|
|
||||||
maximumTrackTintColor="rgba(255,255,255,0.2)"
|
|
||||||
thumbTintColor={Colors.text}
|
|
||||||
onSlidingComplete={(v) => seek(v * duration)}
|
|
||||||
/>
|
|
||||||
<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)}</Text>
|
<Text style={s.timeText}>{fmt(duration)}</Text>
|
||||||
|
|
@ -214,11 +164,10 @@ export default function Player() {
|
||||||
<Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} />
|
<Path d="M22 6L9.5 14 22 22V6z" fill={Colors.text} />
|
||||||
</Svg>
|
</Svg>
|
||||||
</CtlBtn>
|
</CtlBtn>
|
||||||
<Pressable style={s.playBtn} onPress={onPlay}>
|
<Pressable style={s.playBtn} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}>
|
||||||
{playing
|
{playing
|
||||||
? <Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.bg}><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={26} height={26} 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={26} height={26} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" fill={Colors.bg} /></Svg>
|
: <Svg width={26} height={26} viewBox="0 0 24 24" fill={Colors.bg}><Path d="M7 4.5v15l13-7.5L7 4.5z" /></Svg>}
|
||||||
}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<CtlBtn onPress={onNext}>
|
<CtlBtn onPress={onNext}>
|
||||||
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
|
<Svg width={28} height={28} viewBox="0 0 28 28" fill="none">
|
||||||
|
|
@ -231,7 +180,7 @@ export default function Player() {
|
||||||
</CtlBtn>
|
</CtlBtn>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bottom row: lyrics + speed */}
|
{/* Lyrics button */}
|
||||||
<View style={s.bottomRow}>
|
<View style={s.bottomRow}>
|
||||||
<Pressable style={s.lyrBtn} onPress={loadLyrics}>
|
<Pressable style={s.lyrBtn} onPress={loadLyrics}>
|
||||||
<Svg width={14} height={14} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.4)'} strokeWidth={2} viewBox="0 0 24 24">
|
<Svg width={14} height={14} fill="none" stroke={lyricsOpen ? Colors.accent : 'rgba(255,255,255,0.4)'} strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
|
@ -246,47 +195,33 @@ export default function Player() {
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</PanGestureHandler>
|
</PanGestureHandler>
|
||||||
|
|
||||||
{/* Lyrics bottom sheet */}
|
{/* Lyrics sheet */}
|
||||||
{lyricsOpen && (
|
{lyricsOpen && (
|
||||||
<View style={s.lyrSheet}>
|
<View style={s.lyrSheet}>
|
||||||
<Pressable style={s.lyrBg} onPress={() => setLyricsOpen(false)} />
|
<Pressable style={StyleSheet.absoluteFill} onPress={() => setLyricsOpen(false)} />
|
||||||
<BlurView intensity={40} tint="dark" style={s.lyrInner}>
|
<BlurView intensity={40} tint="dark" style={s.lyrInner}>
|
||||||
{/* Handle */}
|
|
||||||
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
|
<Pressable onPress={() => setLyricsOpen(false)} style={s.lyrHandleBar}>
|
||||||
<View style={s.lyrHandle} />
|
<View style={s.lyrHandle} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{/* Mini header */}
|
|
||||||
<View style={s.lyrHeader}>
|
<View style={s.lyrHeader}>
|
||||||
{cover
|
{cover
|
||||||
? <Image source={{ uri: cover }} style={s.lyrThumb} contentFit="cover" />
|
? <Image source={{ uri: cover }} style={s.lyrThumb} contentFit="cover" />
|
||||||
: <View style={[s.lyrThumb, { backgroundColor: Colors.elevated }]} />
|
: <View style={[s.lyrThumb, { backgroundColor: Colors.elevated }]} />}
|
||||||
}
|
|
||||||
<View style={{ flex: 1, minWidth: 0 }}>
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text style={s.lyrTrackTitle} numberOfLines={1}>{track.title}</Text>
|
<Text style={s.lyrTrackTitle} numberOfLines={1}>{track.title}</Text>
|
||||||
<Text style={s.lyrArtist} numberOfLines={1}>{track.artist}</Text>
|
<Text style={s.lyrArtist} numberOfLines={1}>{track.artist}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{/* Lyrics content */}
|
<ScrollView ref={lyricsScrollRef} style={s.lyrScroll} contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||||||
<ScrollView
|
|
||||||
ref={lyricsScrollRef}
|
|
||||||
style={s.lyrScroll}
|
|
||||||
contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: 20 }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{lrcLines.length > 0
|
{lrcLines.length > 0
|
||||||
? lrcLines.map((line, i) => (
|
? lrcLines.map((line, i) => (
|
||||||
<Text
|
<Text key={i} style={[s.lyrLine, i === currentLineIdx && s.lyrLineActive]} onLayout={(e) => { lineHeights.current[i] = e.nativeEvent.layout.height; }}>
|
||||||
key={i}
|
|
||||||
style={[s.lyrLine, i === currentLineIdx && s.lyrLineActive]}
|
|
||||||
onLayout={(e) => { lineHeights.current[i] = e.nativeEvent.layout.height; }}
|
|
||||||
>
|
|
||||||
{line.text}
|
{line.text}
|
||||||
</Text>
|
</Text>
|
||||||
))
|
))
|
||||||
: plainLyrics
|
: plainLyrics
|
||||||
? <Text style={s.plainLyrics}>{plainLyrics}</Text>
|
? <Text style={s.plainLyrics}>{plainLyrics}</Text>
|
||||||
: <Text style={s.lyrEmpty}>Текст не найден</Text>
|
: <Text style={s.lyrEmpty}>Текст не найден</Text>}
|
||||||
}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -300,13 +235,9 @@ function CtlBtn({ onPress, active = false, children }: { onPress: () => void; ac
|
||||||
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} onPressIn={() => { sc.value = withSpring(0.82, { damping: 12 }); }} onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} hitSlop={12} style={[s.ctlBtn, active && s.ctlBtnActive]}>
|
||||||
onPress={onPress}
|
{children}
|
||||||
onPressIn={() => { sc.value = withSpring(0.82, { damping: 12 }); }}
|
</Pressable>
|
||||||
onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }}
|
|
||||||
hitSlop={12}
|
|
||||||
style={[s.ctlBtn, active && s.ctlBtnActive]}
|
|
||||||
>{children}</Pressable>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -333,16 +264,14 @@ function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
|
||||||
<Polyline points="7,23 3,19 7,15" stroke={c} strokeWidth={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} strokeLinecap="round" strokeLinejoin="round" />
|
<Path d="M21 13v2a4 4 0 0 1-4 4H3" stroke={c} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
{repeat === 2 && (
|
{repeat === 2 && <View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>}
|
||||||
<View style={s.repBadge}><Text style={s.repBadgeText}>1</Text></View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(s: number) {
|
function fmt(sec: number) {
|
||||||
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
|
||||||
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
|
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
|
|
@ -350,7 +279,6 @@ const s = StyleSheet.create({
|
||||||
bgDim: { backgroundColor: 'rgba(0,0,0,0.65)' },
|
bgDim: { backgroundColor: 'rgba(0,0,0,0.65)' },
|
||||||
sheet: { flex: 1, paddingHorizontal: 24 },
|
sheet: { flex: 1, paddingHorizontal: 24 },
|
||||||
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 8 },
|
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 8 },
|
||||||
handle: { display: 'none' },
|
|
||||||
topLabel: { color: 'rgba(255,255,255,0.38)', fontSize: 11, fontWeight: '600', letterSpacing: 1.2, textTransform: 'uppercase' },
|
topLabel: { color: 'rgba(255,255,255,0.38)', fontSize: 11, fontWeight: '600', letterSpacing: 1.2, textTransform: 'uppercase' },
|
||||||
closeBtn: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.1)', alignItems: 'center', justifyContent: 'center' },
|
closeBtn: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.1)', alignItems: 'center', justifyContent: 'center' },
|
||||||
coverWrap: { alignItems: 'center', marginVertical: 20 },
|
coverWrap: { alignItems: 'center', marginVertical: 20 },
|
||||||
|
|
@ -367,15 +295,13 @@ const s = StyleSheet.create({
|
||||||
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full },
|
ctlBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: Radius.full },
|
||||||
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
|
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
|
||||||
playBtn: { width: 60, height: 60, borderRadius: 30, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center' },
|
playBtn: { width: 60, height: 60, borderRadius: 30, backgroundColor: Colors.text, alignItems: 'center', justifyContent: 'center' },
|
||||||
bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20 },
|
bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
|
||||||
lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 14, paddingVertical: 8, borderRadius: Radius.full },
|
lyrBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(255,255,255,0.08)', paddingHorizontal: 14, paddingVertical: 8, borderRadius: Radius.full },
|
||||||
lyrBtnText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '600' },
|
lyrBtnText: { color: 'rgba(255,255,255,0.4)', fontSize: Font.xs, fontWeight: '600' },
|
||||||
repBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center' },
|
repBadge: { position: 'absolute', top: -3, right: -3, width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.accent, alignItems: 'center', justifyContent: 'center' },
|
||||||
repBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' },
|
repBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' },
|
||||||
// Lyrics sheet
|
lyrSheet: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' },
|
||||||
lyrSheet: { position: 'absolute', inset: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' },
|
lyrInner: { height: '70%', borderTopLeftRadius: 20, borderTopRightRadius: 20, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.1)' },
|
||||||
lyrBg: { ...StyleSheet.absoluteFillObject },
|
|
||||||
lyrInner: { height: '70%', borderRadius: 20, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.1)' },
|
|
||||||
lyrHandleBar: { alignItems: 'center', paddingTop: 12, paddingBottom: 4 },
|
lyrHandleBar: { alignItems: 'center', paddingTop: 12, paddingBottom: 4 },
|
||||||
lyrHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.25)' },
|
lyrHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.25)' },
|
||||||
lyrHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 1, borderColor: 'rgba(255,255,255,0.06)' },
|
lyrHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 1, borderColor: 'rgba(255,255,255,0.06)' },
|
||||||
|
|
|
||||||
|
|
@ -44,5 +44,5 @@ export const Font = {
|
||||||
xxxl: 32,
|
xxxl: 32,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TAB_BAR_HEIGHT = 54;
|
export const TAB_BAR_HEIGHT = 88;
|
||||||
export const MINI_PLAYER_HEIGHT = 64;
|
export const MINI_PLAYER_HEIGHT = 64;
|
||||||
|
|
|
||||||
55
hooks/PlaybackService.ts
Normal file
55
hooks/PlaybackService.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import TrackPlayer, { Event } from 'react-native-track-player';
|
||||||
|
import { usePlayer } from '../store';
|
||||||
|
import { loadAndPlay } from './useAudio';
|
||||||
|
|
||||||
|
export async function PlaybackService() {
|
||||||
|
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
|
||||||
|
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
|
||||||
|
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteNext, async () => {
|
||||||
|
const state = usePlayer.getState();
|
||||||
|
const ni = state.nextTrack();
|
||||||
|
state.setIndex(ni);
|
||||||
|
const track = state.queue[ni];
|
||||||
|
if (track) await loadAndPlay(track).catch(console.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
TrackPlayer.addEventListener(Event.RemotePrevious, async () => {
|
||||||
|
const state = usePlayer.getState();
|
||||||
|
const progress = await TrackPlayer.getProgress();
|
||||||
|
if (progress.position > 3) {
|
||||||
|
await TrackPlayer.seekTo(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pi = state.prevTrack();
|
||||||
|
state.setIndex(pi);
|
||||||
|
const track = state.queue[pi];
|
||||||
|
if (track) await loadAndPlay(track).catch(console.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) =>
|
||||||
|
TrackPlayer.seekTo(position),
|
||||||
|
);
|
||||||
|
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => {
|
||||||
|
const state = usePlayer.getState();
|
||||||
|
const prevIdx = state.index;
|
||||||
|
|
||||||
|
if (state.repeat === 2) {
|
||||||
|
await TrackPlayer.seekTo(0);
|
||||||
|
await TrackPlayer.play();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ni = state.nextTrack();
|
||||||
|
state.setIndex(ni);
|
||||||
|
|
||||||
|
if (ni === prevIdx) {
|
||||||
|
state.setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = state.queue[ni];
|
||||||
|
if (track) await loadAndPlay(track).catch(console.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,96 +1,106 @@
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Audio, AVPlaybackStatus } from 'expo-av';
|
import TrackPlayer, {
|
||||||
|
State,
|
||||||
|
Capability,
|
||||||
|
AppKilledPlaybackBehavior,
|
||||||
|
usePlaybackState,
|
||||||
|
useProgress,
|
||||||
|
} from 'react-native-track-player';
|
||||||
import { usePlayer } from '../store';
|
import { usePlayer } from '../store';
|
||||||
|
import { coverUrl, Track } from '../services/api';
|
||||||
|
|
||||||
let _sound: Audio.Sound | null = null;
|
let _setupDone = false;
|
||||||
|
let _lastLoadedId: number | null = null;
|
||||||
|
|
||||||
// Module-level singleton so the sound survives re-renders
|
async function ensureSetup() {
|
||||||
export async function getSound() { return _sound; }
|
if (_setupDone) return;
|
||||||
|
_setupDone = true;
|
||||||
export function useAudio() {
|
try {
|
||||||
const initialized = useRef(false);
|
await TrackPlayer.setupPlayer({ minBuffer: 15, maxBuffer: 50, playBuffer: 2.5 });
|
||||||
const {
|
await TrackPlayer.updateOptions({
|
||||||
queue, index, playing,
|
android: {
|
||||||
setPlaying, setProgress, setDuration,
|
appKilledPlaybackBehavior:
|
||||||
nextTrack, setIndex, repeat,
|
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
|
||||||
} = usePlayer();
|
},
|
||||||
|
capabilities: [
|
||||||
const currentTrack = queue[index] ?? null;
|
Capability.Play,
|
||||||
|
Capability.Pause,
|
||||||
// Init audio session once
|
Capability.SkipToNext,
|
||||||
useEffect(() => {
|
Capability.SkipToPrevious,
|
||||||
if (initialized.current) return;
|
Capability.SeekTo,
|
||||||
initialized.current = true;
|
],
|
||||||
Audio.setAudioModeAsync({
|
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
|
||||||
staysActiveInBackground: true,
|
|
||||||
playsInSilentModeIOS: true,
|
|
||||||
interruptionModeIOS: 1,
|
|
||||||
shouldDuckAndroid: true,
|
|
||||||
});
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Player already set up on hot reload — update options only
|
||||||
|
if (e?.message?.toLowerCase().includes('already')) {
|
||||||
|
_setupDone = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_setupDone = false;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a track and start playing. Mutex prevents double-load on the same ID.
|
||||||
|
export async function loadAndPlay(track: Track) {
|
||||||
|
if (_lastLoadedId === track.id) return;
|
||||||
|
_lastLoadedId = track.id;
|
||||||
|
try {
|
||||||
|
await TrackPlayer.reset();
|
||||||
|
await TrackPlayer.add({
|
||||||
|
id: String(track.id),
|
||||||
|
url: track.cdn_url2 || track.cdn_url,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
artwork: coverUrl(track.cover_url) ?? undefined,
|
||||||
|
duration: track.duration,
|
||||||
|
});
|
||||||
|
await TrackPlayer.play();
|
||||||
|
} catch (e) {
|
||||||
|
_lastLoadedId = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function togglePlay() {
|
||||||
|
const { state } = await TrackPlayer.getPlaybackState();
|
||||||
|
if (state === State.Playing) await TrackPlayer.pause();
|
||||||
|
else await TrackPlayer.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seekTo(seconds: number) {
|
||||||
|
await TrackPlayer.seekTo(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call ONCE from the root layout only
|
||||||
|
export function useAudio() {
|
||||||
|
const { queue, index, setPlaying, setProgress, setDuration } = usePlayer();
|
||||||
|
const currentTrack = queue[index] ?? null;
|
||||||
|
const playbackState = usePlaybackState();
|
||||||
|
const progress = useProgress(500);
|
||||||
|
|
||||||
|
// One-time player setup
|
||||||
|
useEffect(() => {
|
||||||
|
ensureSetup().catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load new track whenever index/id changes
|
// Sync TrackPlayer progress → store
|
||||||
|
useEffect(() => {
|
||||||
|
setProgress(progress.position);
|
||||||
|
setDuration(progress.duration);
|
||||||
|
}, [progress.position, progress.duration]);
|
||||||
|
|
||||||
|
// Sync TrackPlayer playing state → store
|
||||||
|
useEffect(() => {
|
||||||
|
setPlaying(playbackState.state === State.Playing);
|
||||||
|
}, [playbackState.state]);
|
||||||
|
|
||||||
|
// Load new track when queue index changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
|
loadAndPlay(currentTrack).catch(console.error);
|
||||||
const load = async () => {
|
|
||||||
// Unload previous
|
|
||||||
if (_sound) {
|
|
||||||
await _sound.stopAsync().catch(() => {});
|
|
||||||
await _sound.unloadAsync().catch(() => {});
|
|
||||||
_sound = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { sound } = await Audio.Sound.createAsync(
|
|
||||||
{ uri: currentTrack.stream_url },
|
|
||||||
{ shouldPlay: true, progressUpdateIntervalMillis: 500 },
|
|
||||||
(status: AVPlaybackStatus) => {
|
|
||||||
if (!status.isLoaded) return;
|
|
||||||
setProgress(status.positionMillis / 1000);
|
|
||||||
setDuration((status.durationMillis ?? 0) / 1000);
|
|
||||||
if (status.isPlaying !== usePlayer.getState().playing) {
|
|
||||||
setPlaying(status.isPlaying);
|
|
||||||
}
|
|
||||||
if (status.didJustFinish) {
|
|
||||||
const state = usePlayer.getState();
|
|
||||||
const ni = state.nextTrack();
|
|
||||||
if (ni !== state.index || state.repeat === 2) {
|
|
||||||
setIndex(ni);
|
|
||||||
} else {
|
|
||||||
setPlaying(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
_sound = sound;
|
|
||||||
};
|
|
||||||
|
|
||||||
load().catch(console.error);
|
|
||||||
}, [currentTrack?.id]);
|
}, [currentTrack?.id]);
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
return { togglePlay, seek: seekTo };
|
||||||
if (!_sound) return;
|
|
||||||
const status = await _sound.getStatusAsync();
|
|
||||||
if (!status.isLoaded) return;
|
|
||||||
if (status.isPlaying) {
|
|
||||||
await _sound.pauseAsync();
|
|
||||||
} else {
|
|
||||||
await _sound.playAsync();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const seek = useCallback(async (seconds: number) => {
|
|
||||||
if (!_sound) return;
|
|
||||||
await _sound.setPositionAsync(seconds * 1000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
_sound?.unloadAsync().catch(() => {});
|
|
||||||
_sound = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { togglePlay, seek };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { getAccessToken, getMe, clearTokens } from '../services/api';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { getAccessToken, getMe, clearTokens, User } from '../services/api';
|
||||||
import { useAuth } from '../store';
|
import { useAuth } from '../store';
|
||||||
|
|
||||||
|
const USER_KEY = 'ns-music-user';
|
||||||
|
|
||||||
|
export async function saveUser(user: User): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCachedUser(): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(USER_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
export function useBootAuth() {
|
export function useBootAuth() {
|
||||||
const { setUser, setReady } = useAuth();
|
const { setUser, setReady } = useAuth();
|
||||||
|
|
||||||
|
|
@ -10,10 +24,26 @@ export function useBootAuth() {
|
||||||
try {
|
try {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
if (!token) { setReady(true); return; }
|
if (!token) { setReady(true); return; }
|
||||||
|
|
||||||
|
// Use cached user for immediate display while validating
|
||||||
|
const cached = await loadCachedUser();
|
||||||
|
if (cached) setUser(cached);
|
||||||
|
|
||||||
|
// Validate token and get fresh data
|
||||||
const me = await getMe();
|
const me = await getMe();
|
||||||
if (me) setUser(me);
|
if (me) {
|
||||||
|
// Preserve plan from initial login since no endpoint exposes it
|
||||||
|
const merged: User = { ...me, plan: cached?.plan || me.plan };
|
||||||
|
setUser(merged);
|
||||||
|
saveUser(merged).catch(() => {});
|
||||||
|
} else {
|
||||||
|
// Token invalid — clear everything
|
||||||
|
await clearTokens();
|
||||||
|
await AsyncStorage.removeItem(USER_KEY);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// Network error — cached user already set above, just mark ready
|
||||||
} finally {
|
} finally {
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}
|
}
|
||||||
|
|
@ -23,5 +53,6 @@ export function useBootAuth() {
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
await clearTokens();
|
await clearTokens();
|
||||||
|
await AsyncStorage.removeItem(USER_KEY).catch(() => {});
|
||||||
useAuth.getState().setUser(null);
|
useAuth.getState().setUser(null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
index.js
Normal file
5
index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import 'expo-router/entry';
|
||||||
|
import TrackPlayer from 'react-native-track-player';
|
||||||
|
import { PlaybackService } from './hooks/PlaybackService';
|
||||||
|
|
||||||
|
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "burmalda-music",
|
"name": "burmalda-music",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "expo-router/entry",
|
"main": "./index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"expo": "~51.0.28",
|
"expo": "~51.0.28",
|
||||||
"expo-router": "~3.5.23",
|
"expo-router": "~3.5.23",
|
||||||
"expo-av": "~14.0.7",
|
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-haptics": "~13.0.1",
|
"expo-haptics": "~13.0.1",
|
||||||
|
|
@ -31,7 +30,8 @@
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"zustand": "^4.5.4",
|
"zustand": "^4.5.4",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/slider": "4.5.2"
|
"@react-native-community/slider": "4.5.2",
|
||||||
|
"react-native-track-player": "^4.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.0",
|
"@babel/core": "^7.24.0",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export async function clearTokens() {
|
||||||
|
|
||||||
// ── Fetch helper ──────────────────────────────────────────────────────────────
|
// ── Fetch helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -59,10 +59,13 @@ export interface Track {
|
||||||
artist: string;
|
artist: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
cover_url: string | null;
|
cover_url: string | null;
|
||||||
stream_url: string;
|
cdn_url: string;
|
||||||
|
cdn_url2: string;
|
||||||
|
filename: string;
|
||||||
album?: string;
|
album?: string;
|
||||||
plays?: number;
|
play_count?: number;
|
||||||
liked?: boolean;
|
source?: string;
|
||||||
|
cdn_synced?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Album {
|
export interface Album {
|
||||||
|
|
@ -89,7 +92,7 @@ export interface Lyrics {
|
||||||
// ── API calls ─────────────────────────────────────────────────────────────────
|
// ── API calls ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getTracks(): Promise<Track[]> {
|
export async function getTracks(): Promise<Track[]> {
|
||||||
const r = await apiFetch('/api/music/tracks');
|
const r = await apiFetch('/api/music/tracks?limit=200');
|
||||||
if (!r.ok) return [];
|
if (!r.ok) return [];
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
@ -97,19 +100,46 @@ export async function getTracks(): Promise<Track[]> {
|
||||||
export async function getAlbums(): Promise<Album[]> {
|
export async function getAlbums(): Promise<Album[]> {
|
||||||
const r = await apiFetch('/api/music/albums');
|
const r = await apiFetch('/api/music/albums');
|
||||||
if (!r.ok) return [];
|
if (!r.ok) return [];
|
||||||
return r.json();
|
const data: Array<{ album: string; artist: string; count: number }> = await r.json();
|
||||||
|
return data.map((a, i) => ({
|
||||||
|
id: i,
|
||||||
|
title: a.album,
|
||||||
|
artist: a.artist,
|
||||||
|
cover_url: null,
|
||||||
|
track_count: a.count,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMe(): Promise<User | null> {
|
export async function getMe(): Promise<User | null> {
|
||||||
const r = await apiFetch('/api/music/profile/me');
|
// Validate token
|
||||||
|
const r = await apiFetch('/api/auth/me');
|
||||||
if (!r.ok) return null;
|
if (!r.ok) return null;
|
||||||
return r.json();
|
const auth = await r.json();
|
||||||
|
|
||||||
|
// Get music profile for username / avatar
|
||||||
|
const pr = await apiFetch('/api/music/profile/me');
|
||||||
|
const profile = pr.ok ? await pr.json() : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: auth.id,
|
||||||
|
email: auth.email,
|
||||||
|
username: profile.username || null,
|
||||||
|
avatar_url: profile.avatar_url || null,
|
||||||
|
plan: 'free', // merged with cached plan in useBootAuth
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchTracks(q: string): Promise<Track[]> {
|
export async function searchTracks(q: string): Promise<Track[]> {
|
||||||
const r = await apiFetch(`/api/music/tracks?q=${encodeURIComponent(q)}`);
|
const r = await apiFetch('/api/music/tracks?limit=200');
|
||||||
if (!r.ok) return [];
|
if (!r.ok) return [];
|
||||||
return r.json();
|
const all: Track[] = await r.json();
|
||||||
|
if (!q.trim()) return all;
|
||||||
|
const lq = q.toLowerCase();
|
||||||
|
return all.filter(t =>
|
||||||
|
t.title.toLowerCase().includes(lq) ||
|
||||||
|
t.artist.toLowerCase().includes(lq) ||
|
||||||
|
(t.album && t.album.toLowerCase().includes(lq))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLyrics(id: number): Promise<Lyrics | null> {
|
export async function fetchLyrics(id: number): Promise<Lyrics | null> {
|
||||||
|
|
@ -118,14 +148,15 @@ export async function fetchLyrics(id: number): Promise<Lyrics | null> {
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function streamUrl(filename: string): string {
|
// cover_url from the server is one of:
|
||||||
return `${BASE}/api/music/stream/${filename}`;
|
// - "https://cdn.nodeshift.space/music/xxx.jpg" (full URL)
|
||||||
}
|
// - "/api/music/cover/xxx.jpg" (relative path)
|
||||||
|
// - "xxx.jpg" (bare filename, legacy)
|
||||||
export function coverUrl(filename: string | null): string | null {
|
export function coverUrl(raw: string | null): string | null {
|
||||||
if (!filename) return null;
|
if (!raw) return null;
|
||||||
if (filename.startsWith('http')) return filename;
|
if (raw.startsWith('http')) return raw;
|
||||||
return `${BASE}/api/music/cover/${filename}`;
|
if (raw.startsWith('/')) return `${BASE}${raw}`;
|
||||||
|
return `${BASE}/api/music/cover/${raw}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LRC parser ────────────────────────────────────────────────────────────────
|
// ── LRC parser ────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue