Home/Blog/Migrating from react-native-track-player to expo-audio on React Native 0.81
TUTORIALS

Migrating from react-native-track-player to expo-audio on React Native 0.81

calendar_todayMar 23, 2026schedule7 min readpersonArtem Porubai

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 truefalsetruefalse 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

  1. expo-audio's isLoaded is unreliable on Android. Don't use it for error detection. It flickers between true and false during normal buffering. Use try/catch and timeouts instead.

  2. Use downloadFirst: true for streaming URLs on Android. ExoPlayer's streaming buffer management causes the player to unload itself during rebuffering. Downloading first eliminates this entirely.

  3. Serialize audio mode transitions. When your app has both playback and recording, a simple promise chain prevents interleaved setAudioModeAsync calls that cause unpredictable behavior.

  4. 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.

  5. 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