+1 (415) 943-4271

Media That Behaves: Migrating Off expo-av to expo-video and expo-audio in React Native

Video and audio used to be the one part of a React Native app you could ignore until late in the project. That stopped being true when expo-av was deprecated and split into two focused libraries: expo-video and expo-audio. Apps that are still on expo-av are now on a dead dependency, and the upgrade is not a drop-in rename — the mental model changed.

This is the migration as we actually run it on client apps, plus the media problems that bite after the migration is done.

Why the split happened

expo-av wrapped video and audio in one Av object with an imperative, promise-heavy status API (loadAsync, playAsync, setStatusAsync) and a status object you polled through callbacks. It predates the New Architecture and it was hard to make both media types fast without compromising one.

The replacements are narrower and synchronous where it matters:

  • expo-video — a VideoPlayer object you create once and a <VideoView /> that renders it. Playback state is read through hooks and events, not a status blob.
  • expo-audio — the same shape for sound: useAudioPlayer, useAudioPlayerStatus, and a separate recorder API.

Both are New Architecture native and both keep the player outside the React tree. That is the single conceptual change that makes the rest of the migration make sense.

The shape of the new video API

import { useVideoPlayer, VideoView } from 'expo-video';
import { useEvent } from 'expo';

export function Player({ uri }: { uri: string }) {
  const player = useVideoPlayer(uri, (p) => {
    p.loop = false;
    p.timeUpdateEventInterval = 0.5;
    p.play();
  });

  const { isPlaying } = useEvent(player, 'playingChange', {
    isPlaying: player.playing,
  });

  return (
    <VideoView
      player={player}
      style={{ width: '100%', aspectRatio: 16 / 9 }}
      allowsFullscreen
      allowsPictureInPicture
      nativeControls
    />
  );
}

Things to notice, because each one is a migration trap:

  1. The player is a native object with mutable properties. You set player.muted = true, you do not re-render a prop. Wrapping every property in React state and syncing it back is the most common way teams make the new API feel worse than the old one.
  2. One player, many views. You can hand the same player to a list cell and to a fullscreen screen; playback continues across the handoff. That is how you build a picture-in-picture-style mini player without re-buffering.
  3. Events, not polling. playingChange, statusChange, timeUpdate, playToEnd. Set timeUpdateEventInterval deliberately — leaving it at a fine granularity for a two-hour video means thousands of bridge-free but still real JS callbacks.
  4. useVideoPlayer owns the lifecycle. Created inside a screen, it is released when the screen unmounts. If you need a player that outlives a screen (a background audio-style podcast player), create it with createVideoPlayer() in a module or context and release it yourself.

The audio side

import { useAudioPlayer, useAudioPlayerStatus, setAudioModeAsync } from 'expo-audio';

const player = useAudioPlayer(require('./chime.mp3'));
const status = useAudioPlayerStatus(player);

// somewhere in app startup
await setAudioModeAsync({
  playsInSilentMode: true,
  shouldPlayInBackground: true,
  interruptionMode: 'duckOthers',
});

The audio-mode call is the part people forget and then file a bug about. On iOS, audio does not play when the hardware switch is silenced unless you say so; on Android, ducking and focus behaviour under a phone call or a navigation prompt is a mode setting, not a default. Decide these per app and set them once at startup.

Recording moved too: useAudioRecorder with an explicit prepareToRecordAsync() before record(), and microphone permission requested through the module rather than a generic permissions helper.

A migration plan that does not stall

We do this in four passes rather than one big branch.

1. Inventory. Grep for expo-av imports and sort them into three buckets: fire-and-forget sound effects, inline video in feeds, and a full-screen or backgrounded player. The buckets have very different risk profiles. Sound effects are a one-hour change. A feed of autoplaying video is a week.

2. Sound effects first. Audio.Sound.createAsync becomes useAudioPlayer + player.play(). Ship that, because it retires a chunk of the dependency surface with almost no regression risk.

3. Video views next, one screen at a time. expo-video and expo-av can coexist during the transition, which is what makes an incremental migration possible. Convert a screen, test it on a real low-end Android device, move on.

4. The always-on player last. Background playback, lock-screen controls, and now-playing metadata are where the real work is. Budget for it separately and do not let it block the first three passes.

One caveat worth flagging early: if you relied on expo-av for audio playback while recording, or for some of the more exotic status fields, check the current expo-audio API surface before you promise a date. The new libraries are deliberately narrower, and a small number of apps need a native module to cover the gap.

Background audio and lock-screen controls

This is a native configuration problem more than a JavaScript one.

  • iOS: add the audio background mode to UIBackgroundModes, set shouldPlayInBackground, and make sure the player is not owned by a screen that unmounts.
  • Android: background playback needs a foreground service with the mediaPlayback type and the matching runtime permission, declared through a config plugin rather than hand-edited manifests.

If you are still checking ios/ and android/ into git, this is a good moment to stop — our config plugins and continuous native generation post covers that move.

For a full lock-screen experience (artwork, queue, skip controls, Android Auto / CarPlay), many apps still reach for a dedicated track-player library on top of the platform media session. expo-audio gives you playback; it is not a full media-session framework. Choose deliberately.

Picture-in-Picture

expo-video supports PiP on both platforms, but the platform requirements are asymmetric. iOS needs the audio background mode entitlement even for muted video, and Android needs the activity flagged as PiP-capable plus startPictureInPicture() called while the app is still foregrounded. Auto-entering PiP on background is a separate opt-in. Test the return path: the user re-expanding the PiP window must land on the same player instance, or they get a black frame and a re-buffer.

Caching, offline, and streaming format

Three decisions that matter more for perceived quality than anything in the player API:

  • Progressive MP4 vs HLS. A single MP4 is fine for short clips. Anything over a minute, or anything watched on cellular, wants HLS with multiple renditions so the player can adapt. Both platforms handle HLS natively; you do not need a JS player.
  • Preloading. In a feed, create the player for the next item before it scrolls into view and call preload-style warming rather than waiting for the view to mount. Pair it with a hard cap — two or three warm players, not twenty — or you will run the device out of decoders. This is the same discipline we describe in lists that don't jank.
  • Offline download. There is no cross-platform "download this HLS stream" primitive. For true offline video, either store progressive files yourself with the file-system API (see our SDK 54+ File API migration) or use platform download APIs behind a small native module. Decide early: it changes your CDN and DRM story.

DRM, briefly

If your content is licensed, you need FairPlay on iOS and Widevine on Android, which means a license server and a player that can talk to it. This is the one media requirement that can invalidate a library choice outright, so establish it in week one, not in QA. Ask the content owner for the required security level in writing — Widevine L1 versus L3 is a hardware conversation, not a code one.

Device-level gotchas we keep seeing

  • Autoplay in a list keeps playing after navigation. The player outlives the view. Pause on blur explicitly; do not assume unmount handles it.
  • Muted autoplay is a product decision, not a default. Set player.muted explicitly and surface an unmute affordance.
  • Low-end Android decoder limits. Devices commonly support a small number of simultaneous hardware decoders. Exceeding it fails silently as a black frame rather than an exception.
  • Silent-mode iOS. Covered above, and still the single most-reported "bug" in audio features.
  • Thermals and battery. Long video sessions throttle. If your app also runs a camera or an on-device model, measure the combination, not each in isolation.
  • Source maps and crash triage. Media crashes are usually native. Make sure your symbolication is in place before you ship — see observability for React Native.

What we would do on a real project

Retire expo-av in passes, get the cheap conversions shipped in the first week, and treat background playback, PiP, offline, and DRM as four separate scoped pieces of work with their own acceptance tests on real hardware. Media is the part of a mobile app where simulator testing lies to you most consistently.

If you have a React Native app with a video or audio surface and an expo-av dependency you have been putting off, get in touch — it is a well-bounded piece of work and we have done it enough times to size it honestly.