diff --git a/app.json b/app.json
index 2d27328..9dc9658 100644
--- a/app.json
+++ b/app.json
@@ -29,6 +29,7 @@
"scheme": "burmalda",
"plugins": [
"expo-router",
+ "react-native-track-player",
[
"expo-updates",
{
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 2125e44..65f5986 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -5,6 +5,7 @@ import { BlurView } from 'expo-blur';
import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg';
import { Colors, TAB_BAR_HEIGHT } from '../../constants/theme';
import { useAuth, usePlayer, useLikes } from '../../store';
+import { useAudio } from '../../hooks/useAudio';
import MiniPlayer from '../../components/MiniPlayer';
import Player from '../../components/Player';
@@ -12,6 +13,7 @@ export default function TabsLayout() {
const { user, ready } = useAuth();
const { expanded } = usePlayer();
const { loadLikes } = useLikes();
+ useAudio(); // single audio controller for the whole app
useEffect(() => {
if (ready && !user) router.replace('/auth');
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 3bc359a..2b3305e 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -2,12 +2,12 @@ import { useEffect, useState, useCallback } from 'react';
import { View, Text, ScrollView, StyleSheet, Pressable, Dimensions, RefreshControl } from 'react-native';
import Animated, { FadeIn, FadeInDown, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { Image } from 'expo-image';
import Svg, { Path } from 'react-native-svg';
import * as Haptics from 'expo-haptics';
import { Colors, Font, Spacing, Radius, TAB_BAR_HEIGHT, MINI_PLAYER_HEIGHT } from '../../constants/theme';
import { getTracks, getAlbums, Track, Album, coverUrl } from '../../services/api';
import { usePlayer, useLikes } from '../../store';
+import CoverImage from '../../components/CoverImage';
const { width } = Dimensions.get('window');
const CARD_W = (width - Spacing.md * 3) / 2;
@@ -47,27 +47,30 @@ export default function HomeScreen() {
refreshControl={}
showsVerticalScrollIndicator={false}
>
- {/* Header */}
NodeShift Music
- {/* Now playing strip (if track exists) */}
- {/* Albums */}
{albums.length > 0 && (
Альбомы
{albums.map((a, i) => (
-
+ {
+ 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);
+ }
+ }} />
))}
)}
- {/* Tracks */}
{tracks.length > 0 && (
Треки
@@ -92,15 +95,21 @@ function NowPlayingStrip() {
const track = queue[index];
if (!track) return null;
const cover = coverUrl(track.cover_url);
+
return (
- {cover && }
+ {/* Blurred cover background */}
+
setExpanded(true)}>
- {cover
- ?
- :
- }
+
Сейчас играет
{track.title}
@@ -108,7 +117,7 @@ function NowPlayingStrip() {
{playing
- ? {[1,2,3].map(i => )}
+ ? {[1, 2, 3].map(i => )}
:
}
@@ -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 style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const cover = coverUrl(album.cover_url);
+
return (
{ scale.value = withSpring(0.95, { damping: 15 }); }}
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
+ onPress={onPress}
style={styles.albumCard}
>
-
+
{album.title}
{album.artist}
+ {album.track_count} треков
);
@@ -151,12 +158,7 @@ function TrackRow({ track, index, liked, onPress, onLike }: {
return (
{index + 1}
-
+
{track.title}
{track.artist}
@@ -177,35 +179,33 @@ function fmt(s: number) {
}
const styles = StyleSheet.create({
- scroll: { flex: 1, backgroundColor: Colors.bg },
- content: { paddingHorizontal: Spacing.md },
- pageHeader: { marginBottom: Spacing.md },
- pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
- section: { marginBottom: Spacing.xl },
- secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
- hRow: { gap: Spacing.md, paddingRight: Spacing.md },
- 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' },
- albumArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 2 },
- trackRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: Spacing.sm, borderRadius: Radius.sm, gap: Spacing.sm },
- trackRowActive: { backgroundColor: Colors.surface },
- 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 },
- trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
- trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
- trackDur: { color: Colors.text3, fontSize: Font.xs },
+ scroll: { flex: 1, backgroundColor: Colors.bg },
+ content: { paddingHorizontal: Spacing.md },
+ pageHeader: { marginBottom: Spacing.md },
+ pageTitle: { color: Colors.text, fontSize: Font.xxl, fontWeight: '800', letterSpacing: -0.5 },
+ section: { marginBottom: Spacing.xl },
+ secTitle: { color: Colors.text, fontSize: Font.lg, fontWeight: '700', marginBottom: Spacing.md },
+ hRow: { gap: Spacing.md, paddingRight: Spacing.md },
+ albumCard: { width: CARD_W },
+ albumTitle: { color: Colors.text, fontSize: Font.sm, fontWeight: '600' },
+ 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 },
+ trackRowActive: { backgroundColor: Colors.surface },
+ trackNum: { width: 24, color: Colors.text3, fontSize: Font.sm, textAlign: 'center' },
+ trackInfo: { flex: 1 },
+ trackTitle: { color: Colors.text, fontSize: Font.md, fontWeight: '600' },
+ trackArtist: { color: Colors.text2, fontSize: Font.sm, marginTop: 2 },
+ trackDur: { color: Colors.text3, fontSize: Font.xs },
// Now playing strip
- npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 },
- npDim: { backgroundColor: 'rgba(0,0,0,0.55)' },
- npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
- npCover: { width: 52, height: 52, borderRadius: Radius.sm },
- npInfo: { flex: 1 },
- 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' },
- npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 },
- npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
- npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 },
- npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 },
+ npStrip: { borderRadius: Radius.lg, overflow: 'hidden', marginBottom: Spacing.xl, height: 80 },
+ npDim: { backgroundColor: 'rgba(0,0,0,0.5)' },
+ npContent: { flex: 1, flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
+ npInfo: { flex: 1 },
+ 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' },
+ npArtist: { color: Colors.text2, fontSize: Font.xs, marginTop: 1 },
+ npDot: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
+ npEq: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 },
+ npBar: { width: 3, backgroundColor: Colors.accent, borderRadius: 2 },
});
diff --git a/app/auth.tsx b/app/auth.tsx
index 555fa19..4b4a945 100644
--- a/app/auth.tsx
+++ b/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 { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api';
import { useAuth } from '../store';
+import { saveUser } from '../hooks/useAuth';
WebBrowser.maybeCompleteAuthSession();
@@ -49,6 +50,7 @@ export default function AuthScreen() {
}
const data = await exchangeCode(code);
await saveTokens(data.access_token, data.refresh_token);
+ await saveUser(data.user);
setUser(data.user);
router.replace('/(tabs)');
} catch (e: any) {
@@ -60,13 +62,13 @@ export default function AuthScreen() {
return (
-
-
-
+
+
+
-
+
{/* Cover */}
-
+
- {/* Meta row */}
+ {/* Meta */}
{track.title}
{track.artist}
- { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }}
- hitSlop={12}
- >
+ { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); toggleLike(track.id); }} hitSlop={12}>
-
+
- {/* Seek bar */}
+ {/* Seek */}
- seek(v * duration)}
- />
+ seekTo(v * duration)} />
{fmt(progress)}
{fmt(duration)}
@@ -214,11 +164,10 @@ export default function Player() {
-
+ { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); togglePlay(); }}>
{playing
- ?
- :
- }
+ ?
+ : }
@@ -231,7 +180,7 @@ export default function Player() {
- {/* Bottom row: lyrics + speed */}
+ {/* Lyrics button */}
@@ -246,47 +195,33 @@ export default function Player() {
- {/* Lyrics bottom sheet */}
+ {/* Lyrics sheet */}
{lyricsOpen && (
- setLyricsOpen(false)} />
+ setLyricsOpen(false)} />
- {/* Handle */}
setLyricsOpen(false)} style={s.lyrHandleBar}>
- {/* Mini header */}
{cover
?
- :
- }
+ : }
{track.title}
{track.artist}
- {/* Lyrics content */}
-
+
{lrcLines.length > 0
? lrcLines.map((line, i) => (
- { lineHeights.current[i] = e.nativeEvent.layout.height; }}
- >
- {line.text}
-
- ))
+ { lineHeights.current[i] = e.nativeEvent.layout.height; }}>
+ {line.text}
+
+ ))
: plainLyrics
? {plainLyrics}
- : Текст не найден
- }
+ : Текст не найден}
@@ -300,13 +235,9 @@ function CtlBtn({ onPress, active = false, children }: { onPress: () => void; ac
const st = useAnimatedStyle(() => ({ transform: [{ scale: sc.value }] }));
return (
- { sc.value = withSpring(0.82, { damping: 12 }); }}
- onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }}
- hitSlop={12}
- style={[s.ctlBtn, active && s.ctlBtnActive]}
- >{children}
+ { sc.value = withSpring(0.82, { damping: 12 }); }} onPressOut={() => { sc.value = withSpring(1, { damping: 12 }); }} hitSlop={12} style={[s.ctlBtn, active && s.ctlBtnActive]}>
+ {children}
+
);
}
@@ -316,9 +247,9 @@ function ShuffleIcon({ active }: { active: boolean }) {
return (
-
+
-
+
);
}
@@ -333,16 +264,14 @@ function RepeatIcon({ repeat }: { repeat: 0 | 1 | 2 }) {
- {repeat === 2 && (
- 1
- )}
+ {repeat === 2 && 1}
);
}
-function fmt(s: number) {
- const m = Math.floor(s / 60), sec = Math.floor(s % 60);
- return `${m}:${sec < 10 ? '0' : ''}${sec}`;
+function fmt(sec: number) {
+ const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
+ return `${m}:${s < 10 ? '0' : ''}${s}`;
}
const s = StyleSheet.create({
@@ -350,7 +279,6 @@ const s = StyleSheet.create({
bgDim: { backgroundColor: 'rgba(0,0,0,0.65)' },
sheet: { flex: 1, paddingHorizontal: 24 },
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' },
closeBtn: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.1)', alignItems: 'center', justifyContent: 'center' },
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 },
ctlBtnActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
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 },
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' },
repBadgeText: { color: '#000', fontSize: 6, fontWeight: '800' },
- // Lyrics sheet
- lyrSheet: { position: 'absolute', inset: 0, bottom: 0, zIndex: 100, justifyContent: 'flex-end' },
- lyrBg: { ...StyleSheet.absoluteFillObject },
- lyrInner: { height: '70%', borderRadius: 20, overflow: 'hidden', borderTopWidth: 1, borderColor: 'rgba(255,255,255,0.1)' },
+ lyrSheet: { position: 'absolute', top: 0, left: 0, right: 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)' },
lyrHandleBar: { alignItems: 'center', paddingTop: 12, paddingBottom: 4 },
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)' },
diff --git a/constants/theme.ts b/constants/theme.ts
index 683f198..7b3133c 100644
--- a/constants/theme.ts
+++ b/constants/theme.ts
@@ -44,5 +44,5 @@ export const Font = {
xxxl: 32,
};
-export const TAB_BAR_HEIGHT = 54;
+export const TAB_BAR_HEIGHT = 88;
export const MINI_PLAYER_HEIGHT = 64;
diff --git a/hooks/PlaybackService.ts b/hooks/PlaybackService.ts
new file mode 100644
index 0000000..c78c73b
--- /dev/null
+++ b/hooks/PlaybackService.ts
@@ -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);
+ });
+}
diff --git a/hooks/useAudio.ts b/hooks/useAudio.ts
index 77f6d46..ebaa9c6 100644
--- a/hooks/useAudio.ts
+++ b/hooks/useAudio.ts
@@ -1,96 +1,106 @@
-import { useEffect, useRef, useCallback } from 'react';
-import { Audio, AVPlaybackStatus } from 'expo-av';
+import { useEffect } from 'react';
+import TrackPlayer, {
+ State,
+ Capability,
+ AppKilledPlaybackBehavior,
+ usePlaybackState,
+ useProgress,
+} from 'react-native-track-player';
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
-export async function getSound() { return _sound; }
-
-export function useAudio() {
- const initialized = useRef(false);
- const {
- queue, index, playing,
- setPlaying, setProgress, setDuration,
- nextTrack, setIndex, repeat,
- } = usePlayer();
-
- const currentTrack = queue[index] ?? null;
-
- // Init audio session once
- useEffect(() => {
- if (initialized.current) return;
- initialized.current = true;
- Audio.setAudioModeAsync({
- staysActiveInBackground: true,
- playsInSilentModeIOS: true,
- interruptionModeIOS: 1,
- shouldDuckAndroid: true,
+async function ensureSetup() {
+ if (_setupDone) return;
+ _setupDone = true;
+ try {
+ await TrackPlayer.setupPlayer({ minBuffer: 15, maxBuffer: 50, playBuffer: 2.5 });
+ await TrackPlayer.updateOptions({
+ android: {
+ appKilledPlaybackBehavior:
+ AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
+ },
+ capabilities: [
+ Capability.Play,
+ Capability.Pause,
+ Capability.SkipToNext,
+ Capability.SkipToPrevious,
+ Capability.SeekTo,
+ ],
+ compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
});
+ } 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(() => {
if (!currentTrack) return;
-
- 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);
+ loadAndPlay(currentTrack).catch(console.error);
}, [currentTrack?.id]);
- const togglePlay = useCallback(async () => {
- 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 };
+ return { togglePlay, seek: seekTo };
}
diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts
index 5df3aaa..eea0c17 100644
--- a/hooks/useAuth.ts
+++ b/hooks/useAuth.ts
@@ -1,7 +1,21 @@
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';
+const USER_KEY = 'ns-music-user';
+
+export async function saveUser(user: User): Promise {
+ await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
+}
+
+export async function loadCachedUser(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(USER_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch { return null; }
+}
+
export function useBootAuth() {
const { setUser, setReady } = useAuth();
@@ -10,10 +24,26 @@ export function useBootAuth() {
try {
const token = await getAccessToken();
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();
- 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 {
- // ignore
+ // Network error — cached user already set above, just mark ready
} finally {
setReady(true);
}
@@ -23,5 +53,6 @@ export function useBootAuth() {
export async function logout() {
await clearTokens();
+ await AsyncStorage.removeItem(USER_KEY).catch(() => {});
useAuth.getState().setUser(null);
}
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..ce52c01
--- /dev/null
+++ b/index.js
@@ -0,0 +1,5 @@
+import 'expo-router/entry';
+import TrackPlayer from 'react-native-track-player';
+import { PlaybackService } from './hooks/PlaybackService';
+
+TrackPlayer.registerPlaybackService(() => PlaybackService);
diff --git a/package.json b/package.json
index f95a331..9978aae 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "burmalda-music",
"version": "1.0.0",
- "main": "expo-router/entry",
+ "main": "./index.js",
"scripts": {
"start": "expo start",
"ios": "expo run:ios",
@@ -12,7 +12,6 @@
"dependencies": {
"expo": "~51.0.28",
"expo-router": "~3.5.23",
- "expo-av": "~14.0.7",
"expo-blur": "~13.0.2",
"expo-constants": "~16.0.2",
"expo-haptics": "~13.0.1",
@@ -31,7 +30,8 @@
"react-native-screens": "3.31.1",
"zustand": "^4.5.4",
"@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": {
"@babel/core": "^7.24.0",
diff --git a/services/api.ts b/services/api.ts
index 0940533..4845c75 100644
--- a/services/api.ts
+++ b/services/api.ts
@@ -1,7 +1,7 @@
import * as SecureStore from 'expo-secure-store';
-const BASE = 'https://music.nodeshift.space';
-const CLOUD = 'https://cloud.nodeshift.space';
+const BASE = 'https://music.nodeshift.space';
+const CLOUD = 'https://cloud.nodeshift.space';
const CLIENT_ID = 'nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U';
const REDIRECT = 'burmalda://auth/callback';
@@ -21,7 +21,7 @@ export async function clearTokens() {
// ── Fetch helper ──────────────────────────────────────────────────────────────
-async function apiFetch(path: string, opts: RequestInit = {}): Promise {
+export async function apiFetch(path: string, opts: RequestInit = {}): Promise {
const token = await getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
@@ -54,31 +54,34 @@ export async function exchangeCode(code: string): Promise<{ access_token: string
// ── Types ─────────────────────────────────────────────────────────────────────
export interface Track {
- id: number;
- title: string;
- artist: string;
- duration: number;
- cover_url: string | null;
- stream_url: string;
- album?: string;
- plays?: number;
- liked?: boolean;
+ id: number;
+ title: string;
+ artist: string;
+ duration: number;
+ cover_url: string | null;
+ cdn_url: string;
+ cdn_url2: string;
+ filename: string;
+ album?: string;
+ play_count?: number;
+ source?: string;
+ cdn_synced?: boolean;
}
export interface Album {
- id: number;
- title: string;
- artist: string;
- cover_url: string | null;
+ id: number;
+ title: string;
+ artist: string;
+ cover_url: string | null;
track_count: number;
}
export interface User {
- id: number;
- email: string;
- username: string | null;
+ id: number;
+ email: string;
+ username: string | null;
avatar_url: string | null;
- plan: string;
+ plan: string;
}
export interface Lyrics {
@@ -89,7 +92,7 @@ export interface Lyrics {
// ── API calls ─────────────────────────────────────────────────────────────────
export async function getTracks(): Promise