Audio and Video
Play video with expo-video and sound with expo-audio: a player object, a view, background playback and releasing the player.
const player = useVideoPlayer(source, (p) => { p.loop = true; })expo-av is no longer part of the SDK. Video is expo-video, audio is expo-audio, and both share one shape: you create a player object, read its state, and let a hook release it.
Video
useVideoPlayer(source, setup?) creates the player. source is a URL string, a local asset, or an object. The optional setup function runs once just after creation, which is where player.loop and player.muted belong. The player is not a view: render <VideoView player={player} /> to show it, with nativeControls (default true) and contentFit of 'contain', 'cover' or 'fill'. Methods are play(), pause(), replaceAsync(source); properties include currentTime, duration, volume and playbackRate.
Player properties are not React state, so changing them does not re-render. To render from them, subscribe: useEvent(player, 'playingChange', { isPlaying: player.playing }) from the expo package. That is the short form of player.addListener('playingChange', ...) plus your own state and cleanup.
Audio
useAudioPlayer(source?) gives an audio player; useAudioPlayerStatus(player) gives a re-rendering snapshot with playing, currentTime, duration, isLoaded, isBuffering and didJustFinish. setAudioModeAsync({ playsInSilentMode: true }) is required for sound to come out while the iOS ringer switch is off.
Releasing
Hook-created players are released on unmount. One made with createVideoPlayer is yours: call release(), or remove() on an audio player.
Example
import { useEvent } from "expo";
import { useVideoPlayer, VideoView } from "expo-video";
import { Button, StyleSheet, View } from "react-native";
const SOURCE = "https://example.com/clip.mp4";
export default function ClipPlayer() {
// The setup function runs once, immediately after the player is created.
const player = useVideoPlayer(SOURCE, (p) => {
p.loop = true;
p.muted = false;
});
// Shorthand for addListener + useState + cleanup in an effect.
const { isPlaying } = useEvent(player, "playingChange", {
isPlaying: player.playing,
});
return (
<View style={styles.screen}>
<VideoView style={styles.video} player={player} contentFit="contain" nativeControls />
<Button
title={isPlaying ? "Pause" : "Play"}
onPress={() => (isPlaying ? player.pause() : player.play())}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, justifyContent: "center", gap: 16, padding: 16 },
video: { width: "100%", height: 220, backgroundColor: "#000" },
});When to use it
- Playing a remote MP4 with native controls on a detail screen.
- Autoplaying muted, looping clips in a scrolling feed.
- Continuing podcast audio while the app is in the background.
- Playing a short sound effect when an action succeeds.
More examples
An audio player with a progress read-out
useAudioPlayerStatus is the audio equivalent of subscribing to events: it turns the player's live state into values you can render, which player.playing on its own cannot do because touching a property does not trigger a render. The audio mode is set once in an effect with an empty dependency array. Reach for this shape for podcasts, voice notes and any player that shows progress.
import { useEffect } from "react";
import { setAudioModeAsync, useAudioPlayer, useAudioPlayerStatus } from "expo-audio";
import { Button, StyleSheet, Text, View } from "react-native";
const TRACK = "https://example.com/episode.mp3";
export default function EpisodePlayer() {
const player = useAudioPlayer(TRACK);
const status = useAudioPlayerStatus(player); // re-renders as playback moves
useEffect(() => {
// Without playsInSilentMode, iOS stays silent when the ringer switch is off.
setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: true });
}, []);
const remaining = Math.max(0, Math.round(status.duration - status.currentTime));
return (
<View style={styles.screen}>
<Text>{status.isLoaded ? `${remaining}s left` : "Loading"}</Text>
<Button
title={status.playing ? "Pause" : "Play"}
onPress={() => (status.playing ? player.pause() : player.play())}
/>
<Button title="Back 15s" onPress={() => player.seekTo(status.currentTime - 15)} />
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, justifyContent: "center", alignItems: "center", gap: 12 },
});Pausing video when the screen loses focus
A player keeps running when you navigate away, because the screen is still mounted. The effect ties playback to focus, so the sound stops with the screen the user left. The player is in the dependency list because it is a value from a hook, and the effect must re-bind if it ever changes. Muted autoplay plus nativeControls={false} is the usual configuration for a scrolling video feed.
import { useEffect } from "react";
import { useIsFocused } from "@react-navigation/native";
import { useVideoPlayer, VideoView } from "expo-video";
import { StyleSheet, View } from "react-native";
type FeedVideoProps = { url: string };
export default function FeedVideo({ url }: FeedVideoProps) {
const isFocused = useIsFocused();
const player = useVideoPlayer(url, (p) => {
p.loop = true;
p.muted = true; // autoplaying feeds start silent
});
useEffect(() => {
if (isFocused) {
player.play();
} else {
player.pause(); // audio would otherwise keep running on the next screen
}
}, [isFocused, player]);
return (
<View style={styles.screen}>
<VideoView style={styles.video} player={player} nativeControls={false} />
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: "#000" },
video: { flex: 1 },
});A player that outlives one component
createVideoPlayer is the escape hatch for a player that must survive component unmounts, for example a background audio session driven from app-level code rather than one screen. The trade is that nothing cleans it up: every path out has to call release(), and the previous player must be released before a new one replaces it. Prefer useVideoPlayer unless you have this specific need, because the hook handles the lifetime for you.
import { createVideoPlayer, type VideoPlayer } from "expo-video";
let current: VideoPlayer | null = null;
export function startPreview(url: string): VideoPlayer {
// Hook-created players are released on unmount. This one is not: it is ours.
current?.release();
current = createVideoPlayer(url);
current.loop = false;
current.play();
return current;
}
export function stopPreview(): void {
current?.release(); // forgetting this leaks native memory and the audio session
current = null;
}
Discussion