Building a robust media player in React Native often involves managing playback state, track information, and user controls. While
react-native-track-player
handles the heavy lifting of audio playback, keeping your React Native UI in perfect sync with the playerβs internal state can be a challenge. This is where state management libraries like Zustand come in.
In this technical blog post, we'll walk through building a powerful auto-sync system between react-native-track-player
and Zustand. By the end, you'll have a useTrackPlayerSync()
hook that keeps your appβs UI perfectly aligned with the audio player, making your development process smoother and your user experience seamless.
Why Auto-Sync?
Imagine your user:
- Plays a track, then minimizes the app.
- Opens the app later β the player UI should reflect the exact state of the background player.
- Interacts with native controls (e.g., skip forward from the lock screen) β your UI needs to update instantly.
Without a robust sync mechanism, your UI can fall out of sync, leading to a confusing experience. Our auto-sync system solves this!
π§ Our Goal: The useTrackPlayerSync()
Hook
We'll build a reusable hook that:
- Subscribes to
react-native-track-player
events - Updates Zustand store automatically
- Handles errors gracefully
- Is easy to plug into your app
β Step 1: Create Zustand Store
Create a file src/stores/usePlayerStore.ts
:
// src/stores/usePlayerStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface PlayerState {
currentTrackId: string | null;
playbackState: 'none' | 'ready' | 'playing' | 'paused' | 'stopped' | 'buffering';
currentPosition: number;
duration: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeatMode: 'none' | 'single' | 'all';
pointA: number | null;
pointB: number | null;
loopActive: boolean;
subscriptionsInitialized: boolean;
error: string | null;
setCurrentTrack: (id: string | null) => void;
setPlaybackState: (s: PlayerState['playbackState']) => void;
setCurrentPosition: (p: number) => void;
setDuration: (d: number) => void;
setVolume: (v: number) => void;
setMuted: (m: boolean) => void;
setShuffle: (s: boolean) => void;
setRepeatMode: (r: PlayerState['repeatMode']) => void;
setPointA: (a: number | null) => void;
setPointB: (b: number | null) => void;
setLoopActive: (l: boolean) => void;
setSubscriptionsInitialized: (v: boolean) => void;
clearLoopPoints: () => void;
setError: (error: string | null) => void;
}
export const usePlayerStore = create<PlayerState>()(
persist(
(set) => ({
currentTrackId: null,
playbackState: 'none',
currentPosition: 0,
duration: 0,
volume: 1,
muted: false,
shuffle: false,
repeatMode: 'none',
pointA: null,
pointB: null,
loopActive: false,
subscriptionsInitialized: false,
error: null,
setCurrentTrack: (id) => set({ currentTrackId: id }),
setPlaybackState: (s) => set({ playbackState: s }),
setCurrentPosition: (p) => set({ currentPosition: p }),
setDuration: (d) => set({ duration: d }),
setVolume: (v) => set({ volume: v }),
setMuted: (m) => set({ muted: m }),
setShuffle: (s) => set({ shuffle: s }),
setRepeatMode: (r) => set({ repeatMode: r }),
setPointA: (a) => set({ pointA: a }),
setPointB: (b) => set({ pointB: b }),
setLoopActive: (l) => set({ loopActive: l }),
setSubscriptionsInitialized: (v) => set({ subscriptionsInitialized: v }),
clearLoopPoints: () => set({ pointA: null, pointB: null, loopActive: false }),
setError: (e) => set({ error: e }),
}),
{
name: 'player-store',
}
)
);
π§© Step 2: The useTrackPlayerSync()
Hook
Create src/hooks/useTrackPlayerSync.ts
:
import { useEffect } from 'react';
import TrackPlayer, {
Event,
State as TrackPlayerState,
Track,
} from 'react-native-track-player';
import { usePlayerStore, PlayerState } from '../stores/usePlayerStore';
function trackStateToString(state: TrackPlayerState): PlayerState['playbackState'] {
switch (state) {
case TrackPlayerState.Playing: return 'playing';
case TrackPlayerState.Paused: return 'paused';
case TrackPlayerState.Buffering: return 'buffering';
case TrackPlayerState.Stopped: return 'stopped';
case TrackPlayerState.Ready: return 'ready';
case TrackPlayerState.None: return 'none';
default:
console.warn(`Unknown state: ${state}`);
return 'none';
}
}
export function useTrackPlayerSync() {
const {
setCurrentTrack, setPlaybackState, setCurrentPosition,
setDuration, setVolume, setSubscriptionsInitialized, setError,
} = usePlayerStore();
useEffect(() => {
const setup = async () => {
setError(null);
try {
const activeTrack: Track | null = await TrackPlayer.getActiveTrack();
const progress = await TrackPlayer.getProgress();
const volume = await TrackPlayer.getVolume();
const playbackState = await TrackPlayer.getPlaybackState();
setCurrentTrack(activeTrack?.id ?? null);
setCurrentPosition(progress.position);
setDuration(progress.duration);
setVolume(volume);
setPlaybackState(trackStateToString(playbackState.state));
setSubscriptionsInitialized(true);
} catch (error: any) {
console.error("TrackPlayer setup error:", error);
setError(`Init error: ${error.message || 'Unknown'}`);
setSubscriptionsInitialized(false);
}
};
setup();
const subs = [
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, (data) => {
try {
setCurrentTrack(data.track?.id ?? null);
setError(null);
} catch (e: any) {
setError(`Track change error: ${e.message}`);
}
}),
TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => {
try {
setPlaybackState(trackStateToString(state));
} catch (e: any) {
setError(`State error: ${e.message}`);
}
}),
TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ position, duration }) => {
setCurrentPosition(position);
setDuration(duration);
}),
TrackPlayer.addEventListener(Event.PlaybackError, (err) => {
setError(`Playback error: ${err.message || err.code}`);
setPlaybackState('stopped');
}),
];
return () => {
subs.forEach((sub) => sub.remove());
setSubscriptionsInitialized(false);
setError(null);
};
}, [
setCurrentTrack, setPlaybackState, setCurrentPosition,
setDuration, setVolume, setSubscriptionsInitialized, setError,
]);
}
π Step 3: Use the Hook in App
// App.tsx
import React, { useEffect } from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import TrackPlayer from 'react-native-track-player';
import { useTrackPlayerSync } from './src/hooks/useTrackPlayerSync';
import { usePlayerStore } from './src/stores/usePlayerStore';
async function setupTrackPlayer() {
try {
await TrackPlayer.setupPlayer();
await TrackPlayer.updateOptions({
capabilities: [
TrackPlayer.Capability.Play,
TrackPlayer.Capability.Pause,
TrackPlayer.Capability.SkipToNext,
TrackPlayer.Capability.SkipToPrevious,
TrackPlayer.Capability.Stop,
],
});
} catch (error) {
console.error('TrackPlayer setup failed:', error);
}
}
export default function App() {
useEffect(() => {
setupTrackPlayer();
}, []);
useTrackPlayerSync();
const { playbackState, currentTrackId, currentPosition, duration, error, subscriptionsInitialized } = usePlayerStore();
return (
<View style={styles.container}>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Error: {error}</Text>
</View>
)}
{!subscriptionsInitialized ? (
<ActivityIndicator size="large" />
) : (
<View>
<Text>Track: {currentTrackId || 'None'}</Text>
<Text>State: {playbackState}</Text>
<Text>Time: {currentPosition.toFixed(1)}s / {duration.toFixed(1)}s</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
errorContainer: { backgroundColor: '#fee', padding: 10 },
errorText: { color: '#900' },
});
π Syncing Volume & Settings from UI
import React, { useEffect } from 'react';
import { Text, View } from 'react-native';
import { usePlayerStore } from '../stores/usePlayerStore';
import TrackPlayer from 'react-native-track-player';
export function PlayerControls() {
const { volume, muted, repeatMode, setVolume, setMuted, setRepeatMode, setError } = usePlayerStore();
useEffect(() => {
const applyVolume = async () => {
try {
await TrackPlayer.setVolume(muted ? 0 : volume);
setError(null);
} catch (e: any) {
setError(`Volume error: ${e.message}`);
}
};
applyVolume();
}, [volume, muted]);
return (
<View>
<Text>Volume: {(volume * 100).toFixed(0)}% {muted ? '(Muted)' : ''}</Text>
<Text>Repeat Mode: {repeatMode}</Text>
</View>
);
}
β Benefits Recap
- Single source of truth with Zustand
- Automatic sync using TrackPlayer events
- Modern API support
- Error handling baked in
- Reusability across apps
- Clean and scalable architecture
π Conclusion
By building a useTrackPlayerSync()
hook and managing your state with Zustand, your React Native audio player becomes more reliable, resilient, and ready for production.
Happy syncing β and even happier users. π§
Top comments (0)