expo-video Tutorial: React Native Video Playback, HLS, and Migration from expo-av (2026)

Migrate from expo-av to expo-video in 2026: useVideoPlayer hook, HLS streaming, picture-in-picture, background audio, subtitles, and the gotchas that trip up most migrations.

expo-video Guide: expo-av Migration 2026

If you've shipped React Native apps with video, you've probably leaned on expo-av for years. Honestly, I have too — and that era is finally ending. Starting with Expo SDK 53, expo-av got officially deprecated, and Expo SDK 55 (the current release in 2026) recommends a clean split: expo-video for video playback and expo-audio for audio. The new packages are built on the New Architecture, use modern native APIs (AVPlayer on iOS, ExoPlayer/Media3 on Android), and give you a cleaner imperative API with hooks designed for React.

This guide walks through everything you need to migrate from expo-av to expo-video in production: installation, the useVideoPlayer hook, custom controls, HLS adaptive streaming, picture-in-picture, fullscreen, background playback — plus the gotchas nobody really warns you about. Every example here runs on Expo SDK 55 with React Native 0.81 and the New Architecture enabled.

Why expo-av Was Replaced

The original expo-av tried to do too much. A single Video component handled audio sessions, video rendering, picture-in-picture, captions, and lifecycle — all wired through bridge-based callbacks that fought the React render cycle. On the New Architecture, that design started showing its age: dropped frames during state updates, finicky cleanup, and a callback-heavy API that just didn't fit modern hooks.

expo-video takes a different approach. Player state lives outside React, in a native object you create with useVideoPlayer. The <VideoView> component is a thin surface that just renders the player. State changes don't re-mount the player, controls update through events you subscribe to, and lifecycle is explicit. The result? Smoother playback, predictable behavior, and roughly 40% less native code in a typical video screen.

Installing expo-video in 2026

You'll need Expo SDK 52 or newer. SDK 55 is what I'd recommend, mainly because it includes the useEventListener helper, generated subtitles support, and the now-stable contentFit API.

npx expo install expo-video

The package ships with config plugin support. Add it to app.json if you need background playback or picture-in-picture:

{
  "expo": {
    "plugins": [
      [
        "expo-video",
        {
          "supportsBackgroundPlayback": true,
          "supportsPictureInPicture": true
        }
      ]
    ]
  }
}

Then rebuild your dev client. Config plugins write into Info.plist (UIBackgroundModes audio) and AndroidManifest.xml; managed Expo Go won't work for either feature — you've been warned.

npx expo prebuild --clean
npx expo run:ios
npx expo run:android

Your First expo-video Player

So, the mental model: create a player instance, attach it to a view. That's really it. Here's the minimum viable example.

import { useVideoPlayer, VideoView } from 'expo-video';
import { StyleSheet, View } from 'react-native';

const videoSource =
  'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';

export default function VideoScreen() {
  const player = useVideoPlayer(videoSource, (player) => {
    player.loop = true;
    player.play();
  });

  return (
    <View style={styles.container}>
      <VideoView
        style={styles.video}
        player={player}
        allowsFullscreen
        allowsPictureInPicture
        contentFit="contain"
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center' },
  video: { width: '100%', height: 220 },
});

The setup callback runs once, when the player is created. Anything you set inside it — loop, volume, playbackRate, muted — applies before the first frame.

Subscribing to Player State with useEvent

Because the player lives outside React, reading its current state means subscribing to events. expo-video ships useEvent for exactly this:

import { useEvent } from 'expo';
import { useVideoPlayer, VideoView } from 'expo-video';
import { Button, Text, View } from 'react-native';

export default function PlayerWithControls() {
  const player = useVideoPlayer(videoSource, (p) => {
    p.play();
  });

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

  const { status, error } = useEvent(player, 'statusChange', {
    status: player.status,
  });

  return (
    <View>
      <VideoView style={{ width: '100%', height: 220 }} player={player} />
      <Text>Status: {status}</Text>
      {error && <Text>Error: {error.message}</Text>}
      <Button
        title={isPlaying ? 'Pause' : 'Play'}
        onPress={() => (isPlaying ? player.pause() : player.play())}
      />
    </View>
  );
}

Available events include playingChange, statusChange, playbackRateChange, volumeChange, mutedChange, sourceChange, timeUpdate, and playToEnd. The third argument to useEvent is the initial value, so your component renders correctly on first mount — before any event has actually fired.

HLS and Adaptive Streaming

For production video — courses, social feeds, live streams — you almost certainly want HLS rather than a flat MP4. The good news: expo-video supports HLS natively on both platforms via AVPlayer and ExoPlayer.

const hlsSource = {
  uri: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
  contentType: 'hls',
};

const player = useVideoPlayer(hlsSource, (p) => {
  p.bufferOptions = {
    preferredForwardBufferDuration: 8,
    waitsToMinimizeStalling: true,
    minBufferForPlayback: 2,
  };
  p.play();
});

Setting contentType: 'hls' tells the player to skip MIME sniffing and go straight to HLS — a small but real speedup on the first play. The bufferOptions let you tune for bandwidth-constrained scenarios. For mobile-data-heavy apps, I'd lower preferredForwardBufferDuration to 4 seconds and turn on waitsToMinimizeStalling.

If you serve DRM-protected streams, pass a drm object:

const drmSource = {
  uri: 'https://example.com/video.m3u8',
  contentType: 'hls',
  drm: {
    type: 'fairplay',
    licenseServer: 'https://license.example.com/fairplay',
    certificateUrl: 'https://license.example.com/cert',
    headers: { Authorization: `Bearer ${token}` },
  },
};

Use 'widevine' for Android. ClearKey is supported on both platforms (handy for testing).

Picture-in-Picture in 2026

iOS 18 and Android 14 both support proper PiP windows that survive app backgrounding. Wire it up by enabling the config plugin (above) and the prop:

<VideoView
  player={player}
  allowsPictureInPicture
  startsPictureInPictureAutomatically
  onPictureInPictureStart={() => console.log('Entered PiP')}
  onPictureInPictureStop={() => console.log('Exited PiP')}
/>

Android also requires android.supportsPictureInPicture: true in app.json and a target SDK of 31+. Expo SDK 55 sets this by default, but if you've got a custom config, it's worth double-checking.

Background Audio Playback

Audio that continues when the user locks the phone — a pretty common requirement for podcast and music apps. With expo-video:

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

const player = useVideoPlayer(audioSource, (p) => {
  p.staysActiveInBackground = true;
  p.showNowPlayingNotification = true;
  p.play();
});

useEffect(() => {
  return () => {
    player.release();
  };
}, [player]);

showNowPlayingNotification registers a media session that surfaces in iOS Control Center and the Android lock-screen media notification. Always call player.release() in cleanup — unlike expo-av, the new API does not auto-dispose when the component unmounts if the player is still referenced elsewhere. I learned that the hard way after shipping a podcast feature that quietly leaked players for a week before anyone noticed the battery complaints.

Migrating from expo-av: A Field Guide

The migration is mechanical, but it's surprisingly easy to get wrong because the APIs look superficially similar. Here are the swaps that actually matter.

Imports and Component Shape

// Before (expo-av)
import { Video, ResizeMode } from 'expo-av';

<Video
  source={{ uri }}
  resizeMode={ResizeMode.CONTAIN}
  shouldPlay
  isLooping
  useNativeControls
/>

// After (expo-video)
import { useVideoPlayer, VideoView } from 'expo-video';

const player = useVideoPlayer({ uri }, (p) => {
  p.loop = true;
  p.play();
});

<VideoView player={player} contentFit="contain" nativeControls />

Replacing onPlaybackStatusUpdate

The single mega-callback is gone (good riddance). Subscribe to specific events with useEvent instead:

// Before
<Video onPlaybackStatusUpdate={(status) => {
  if (status.isLoaded) {
    setPosition(status.positionMillis);
    setDuration(status.durationMillis);
  }
}} />

// After
const { currentTime } = useEvent(player, 'timeUpdate', {
  currentTime: player.currentTime,
});
const duration = player.duration;

timeUpdate fires roughly every 250ms during playback. One thing to flag loudly: currentTime and duration are now in seconds, not milliseconds. This silently breaks about half of the migration code I've seen — so grep your codebase for positionMillis and durationMillis before you ship.

Seeking

// Before
videoRef.current.setPositionAsync(30000);

// After
player.currentTime = 30; // seconds
// Or for precise seeking:
await player.seekBy(10);

Audio Mode

If you used Audio.setAudioModeAsync, that lives in expo-audio now:

import { setAudioModeAsync } from 'expo-audio';

await setAudioModeAsync({
  playsInSilentMode: true,
  shouldPlayInBackground: true,
  interruptionMode: 'doNotMix',
});

Subtitles and Multiple Audio Tracks

HLS streams with multiple text and audio tracks are handled automatically. To list and select them:

const { availableSubtitleTracks } = useEvent(
  player,
  'availableSubtitleTracksChange',
  { availableSubtitleTracks: player.availableSubtitleTracks }
);

const enableEnglish = () => {
  const track = availableSubtitleTracks.find((t) => t.language === 'en');
  if (track) player.subtitleTrack = track;
};

const disableSubtitles = () => {
  player.subtitleTrack = null;
};

The same pattern works for availableAudioTracks and audioTrack. SDK 55 also added generatedSubtitleTracks for iOS 18+ live captions, which is genuinely magical the first time you see it work.

Performance Tips Nobody Documents

  • Reuse players across screens. If you navigate from a feed thumbnail to a detail view, don't tear down and recreate the player. Hoist useVideoPlayer into a context or a Zustand store and pass the same instance to both VideoView components.
  • Preload thumbnails. Set player.preservesPitch = true and call player.replaceAsync(source) instead of recreating the player when the source changes — much cheaper on memory.
  • Lower buffer for vertical feeds. TikTok-style feeds work better with preferredForwardBufferDuration: 2 so you can swipe quickly without wasting bandwidth.
  • Disable when off-screen. Use FlashList's onViewableItemsChanged to call player.pause() when a tile leaves the viewport. Forgetting this is, hands down, the #1 cause of unexpected battery drain in video feed apps.
  • Set showsTimecodes only in dev. The performance overlay isn't free.

Common Errors and Fixes

"Cannot read property 'play' of undefined" after fast refresh

Fast Refresh sometimes recreates the player while events are still firing. Wrap event handlers in a guard:

useEffect(() => {
  const sub = player.addListener('playingChange', (e) => {
    if (!player) return;
    setIsPlaying(e.isPlaying);
  });
  return () => sub.remove();
}, [player]);

HLS playback fails on Android only

Older Android devices need cleartext traffic explicitly enabled if your stream is HTTP. Add "usesCleartextTraffic": true under the android key in app.json — but really, prefer HTTPS streams in production.

Video freezes on iOS after returning from background

If you didn't set staysActiveInBackground, iOS will pause the player and sometimes leave the view in a stuck state. Either enable background playback or call player.play() in an AppState listener on resume.

Frequently Asked Questions

Is expo-av still usable in 2026?

Yes — it still installs and runs on SDK 55, but no new features will be added and bugs are unlikely to be fixed. New projects should start with expo-video and expo-audio; existing projects should plan migration before SDK 56, which is expected to remove expo-av entirely.

Does expo-video work with React Native CLI (not Expo)?

Yes. Install the package, run pod install for iOS, and add the Expo modules autolinking script. The Expo Modules API works in any React Native 0.74+ app — you don't actually need the rest of Expo.

Can I play YouTube or Vimeo URLs directly?

No. Both services serve their videos behind protected URLs that require their official SDKs or the iframe player. For YouTube, use react-native-youtube-iframe. For Vimeo, you'll need a Pro account that lets you serve direct MP4 or HLS URLs, which expo-video can then play.

How do I show a custom poster image before playback starts?

Pass a metadata object on the source with an artwork URL, or render an <Image> overlay you hide when the statusChange event reports readyToPlay. The library does not yet ship a built-in poster prop — and honestly, it's the most-requested missing feature on GitHub.

Is hardware decoding used by default?

Yes, on both platforms. iOS uses VideoToolbox via AVPlayer; Android uses Media3/ExoPlayer with the device's hardware decoder. You don't need to configure anything — the player picks the best decoder for the codec, including HEVC and AV1 on supported devices.

Wrapping Up

The expo-av to expo-video migration is one of the higher-impact upgrades you can do this year: smoother playback, a smaller bundle, a hook-shaped API that fits modern React, and a clear path to features like generated captions and proper PiP. Most apps can migrate in a single afternoon. The main traps? Seconds-vs-milliseconds in time fields, and remembering to release() long-lived players.

If you're starting a new video-heavy feature in 2026, just skip expo-av entirely. The new API is what the next two years of Expo will be built on — might as well get there now.

About the Author Editorial Team

Our team of expert writers and editors.