Migrating from react-native-track-player to expo-audio on React Native 0.81
The Problem: Silent Failure on New Architecture
After upgrading to React Native 0.81 with New Architecture (TurboModules) enabled, our audio player stopped working entirely. Users saw an infinite spinner when trying to play stories — no errors in the console, no crash, just silence.
The root cause: react-native-track-player (both v4 stable and v5-alpha) doesn't support TurboModules. The native module fails to register, so every player operation — play(), pause(), seekTo() — silently returns without doing anything. The JS API never throws; it just does nothing.
Since react-native-track-player had no timeline for New Architecture support, we decided to migrate to expo-audio, Expo's official audio package for SDK 54.
The Migration Strategy: Preserve the Public Interface
Our audio player architecture uses a layered pattern:
Player UI (components) → useChapterAudio (hook) → usePlayerStore (Zustand) → audio engine
The key insight: only the bottom layer touches the audio engine directly. By replacing the internals of the Zustand store while preserving its exact interface (18 state fields, 11 action methods), zero consumer code needed to change — no modifications to the player UI, mini-player, or chapter screens.
Architecture: Three New Files
1. Singleton Player Manager (audio-player.ts)
Replaces the old track-player-loader.ts. Manages a single AudioPlayer instance with an observer pattern for lifecycle changes:
import { createAudioPlayer, type AudioPlayer } from "expo-audio";
let _player: AudioPlayer | null = null;
const _listeners = new Set<(player: AudioPlayer | null) => void>();
export function createAudioPlayerForUrl(url: string): AudioPlayer {
if (_player) {
_releasePlayer(_player);
}
_player = createAudioPlayer(
{ uri: url },
{ updateInterval: 250, downloadFirst: true }
);
_notifyListeners();
return _player;
}
export function destroyAudioPlayer(): void {
if (_player) {
_releasePlayer(_player);
_player = null;
}
_notifyListeners();
}
function _releasePlayer(player: AudioPlayer): void {
try {
player.setActiveForLockScreen(false);
} catch {
/* player may not have been set active */
}
player.remove();
}
export function onPlayerChange(
cb: (player: AudioPlayer | null) => void
): () => void {
_listeners.add(cb);
return () => _listeners.delete(cb);
}The downloadFirst: true option is critical for Android — more on that later.
2. Audio Session Coordinator (audio-session.ts)
Our app has two audio consumers: expo-audio for story playback and expo-av for voice recording. These need different audio session configurations, and switching between them must be serialized to prevent race conditions:
import { setAudioModeAsync } from "expo-audio";
import { Audio as ExpoAvAudio } from "expo-av";
let _pending: Promise<void> = Promise.resolve();
function serialized(fn: () => Promise<void>): Promise<void> {
_pending = _pending.then(fn, fn);
return _pending;
}
export function setPlaybackMode(): Promise<void> {
return serialized(() =>
setAudioModeAsync({
shouldPlayInBackground: true,
interruptionMode: "mixWithOthers",
playsInSilentMode: true,
})
);
}
export function setRecordingMode(): Promise<void> {
return serialized(() =>
ExpoAvAudio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
})
);
}The single-flight promise chain (_pending = _pending.then(fn, fn)) ensures mode transitions never interleave, even when the user rapidly switches between playback and recording flows.
3. Status Sync Component (player-sync.tsx)
Replaces TrackPlayer's useProgress, usePlaybackState, and useTrackPlayerEvents hooks with a single playbackStatusUpdate listener:
export function PlayerSync() {
useEffect(() => {
let statusSub: { remove(): void } | null = null;
function attachToPlayer(player: AudioPlayer | null) {
statusSub?.remove();
statusSub = null;
if (!player) return;
statusSub = player.addListener(
"playbackStatusUpdate",
(status) => {
const store = usePlayerStore.getState();
// Progress sync
if (status.isLoaded && status.duration > 0) {
usePlayerStore.setState({
positionMs: Math.floor(status.currentTime * 1000),
durationMs: Math.floor(status.duration * 1000),
});
}
// Play state sync
if (status.playing !== store.isPlaying) {
usePlayerStore.setState({ isPlaying: status.playing });
}
// Finish detection
if (status.didJustFinish) {
usePlayerStore.setState({
isPlaying: false,
positionMs: 0,
didJustFinish: true,
});
}
}
);
}
attachToPlayer(getAudioPlayer());
const unsub = onPlayerChange(attachToPlayer);
return () => { unsub(); statusSub?.remove(); };
}, []);
return null;
}The onPlayerChange subscription re-attaches the listener whenever a new player is created (new track loaded) or the player is destroyed.
The Store Rewrite: Same Interface, New Internals
The mapping from TrackPlayer to expo-audio was mostly mechanical:
| TrackPlayer | expo-audio |
|---|---|
| tp.reset() | destroyAudioPlayer() |
| tp.add({ url }) | createAudioPlayerForUrl(url) |
| tp.play() | player.play() |
| tp.pause() | player.pause() |
| tp.seekTo(seconds) | await player.seekTo(seconds) |
| PlaybackQueueEnded event | status.didJustFinish |
| PlaybackError event | try/catch + timeout |
One critical difference: createAudioPlayer starts loading asynchronously. The store must wait for isLoaded before marking the chapter as ready:
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
sub.remove();
reject(new Error("Audio load timeout"));
}, 15_000);
const sub = player.addListener(
"playbackStatusUpdate",
(status) => {
if (status.isLoaded) {
clearTimeout(timeout);
sub.remove();
resolve();
}
}
);
// Already loaded (cached)?
if (player.isLoaded) {
clearTimeout(timeout);
sub.remove();
resolve();
}
});Android Debugging: The isLoaded Flickering Saga
This is where things got interesting. On iOS, the migration worked on the first try. On Android, we went through four rounds of debugging.
Round 1: clearLockScreenControls is not a function
The first runtime error on Android:
TypeError: _player.clearLockScreenControls is not a function
The method exists in expo-audio's TypeScript definitions and native Kotlin/Swift code, but calling it on a player that was never set active for lock screen throws on Android. Fix: replace clearLockScreenControls() with setActiveForLockScreen(false) wrapped in try-catch.
Round 2: Infinite reload loop
After fixing the crash, the player entered an infinite loop: load → ready → error → reload → load → ready → error...
The PlayerSync component was detecting isLoaded going from true to false as a playback error, which triggered needsReload, which invalidated the signed URL query, which returned a new URL, which triggered a fresh loadChapter.
The problem: on Android, isLoaded flickers. It goes true → false → true → false during normal buffering. This makes isLoaded useless as an error detection signal. We removed the isLoaded-based error detection entirely.
Round 3: Play starts then immediately stops
With the reload loop fixed, play() would succeed momentarily — one or two status updates showing playing=true — then the player would unload itself:
[player-sync] status: loaded=true playing=true duration=325 time=6
[player-sync] status: loaded=true playing=true duration=325 time=6
[player-sync] status: loaded=false playing=false duration=325 time=6
This happened regardless of audio session configuration. The root cause: Android's media player unloads streaming audio during internal buffer transitions. The audio data is being streamed from a CloudFront signed URL, and Android's ExoPlayer (used internally by expo-audio) aggressively releases resources during rebuffering.
Round 4: The downloadFirst fix
The solution: downloadFirst: true in the player options:
_player = createAudioPlayer(
{ uri: url },
{ updateInterval: 250, downloadFirst: true }
);This tells expo-audio to download the entire audio file to the device's temp directory before starting playback. It adds a few seconds to the initial load time, but once loaded, the player doesn't flicker — no more streaming buffer transitions, no more spontaneous unloading.
Bonus: Android Safe Area Fixes
While testing on Android, we noticed bottom buttons were covered by the system navigation bar on 16 screens. The root cause: SafeAreaView without explicit edges prop, or with only edges={["top"]}.
The fix was simple but widespread:
// Before
<SafeAreaView style={styles.container}>
<SafeAreaView style={styles.container} edges={["top"]}>
// After
<SafeAreaView style={styles.container} edges={["top", "bottom"]}>For absolutely-positioned elements like our "Listen Chapter" sticky CTA, the fix was different — add insets.bottom to the bottom offset:
const ctaBottomOffset = insets.bottom + (audioPlayingForOtherStory
? MINI_PLAYER_HEIGHT + spacing.md + spacing.sm
: 0);Key Takeaways
-
expo-audio's
isLoadedis unreliable on Android. Don't use it for error detection. It flickers betweentrueandfalseduring normal buffering. Usetry/catchand timeouts instead. -
Use
downloadFirst: truefor streaming URLs on Android. ExoPlayer's streaming buffer management causes the player to unload itself during rebuffering. Downloading first eliminates this entirely. -
Serialize audio mode transitions. When your app has both playback and recording, a simple promise chain prevents interleaved
setAudioModeAsynccalls that cause unpredictable behavior. -
Preserve your public API surface during migrations. By keeping the Zustand store interface identical, we changed 37 files but only 4 contained actual logic changes. The other 33 were safe area fixes and test updates.
-
Always test on real Android devices. The iOS simulator shares the Mac's audio stack and hides many Android-specific issues. Every round of debugging in this migration only surfaced on Android hardware.
The Numbers
- 37 files changed, 450 insertions, 1,053 deletions (net negative — removed more code than added)
- 0 consumer code changes — player UI, mini-player, chapter screens untouched
- 262 tests passing after migration
- 16 screens fixed for Android bottom safe area