Build a production-ready video player with state management, error handling, and custom controls in React Native using expo-video.
This guide covers everything you need to know to build a robust, production-ready video player in React Native using Mux and expo-video. If you haven't already, start with the quickstart guide to get basic playback working.
Mux delivers video using HLS (HTTP Live Streaming), which is natively supported on both iOS and Android. This means:
Learn more about how Mux handles video streaming in the main documentation.
Every Mux video has a playback ID that you use to construct the streaming URL:
https://stream.mux.com/{PLAYBACK_ID}.m3u8Mux supports two types of playback policies:
For signed playback, you'll need to generate a JWT on your backend and include it as a query parameter:
https://stream.mux.com/{PLAYBACK_ID}.m3u8?token={JWT}Learn how to secure video playback with signed URLs including JWT generation and domain restrictions.
For React Native apps, handle signed URLs by fetching the token from your backend before playing:
import React, { useState, useEffect } from 'react';
import { View, ActivityIndicator, StyleSheet } from 'react-native';
import { useVideoPlayer, VideoView } from 'expo-video';
function SecureVideoPlayer({ playbackId }: { playbackId: string }) {
const [videoUrl, setVideoUrl] = useState<string | null>(null);
useEffect(() => {
// Fetch signed URL from your backend
fetch('https://your-api.com/video/signed-url', {
method: 'POST',
body: JSON.stringify({ playbackId }),
})
.then(res => res.json())
.then(data => setVideoUrl(data.url));
}, [playbackId]);
const player = useVideoPlayer(videoUrl, player => {
player.play();
});
if (!videoUrl) {
return <ActivityIndicator />;
}
return (
<VideoView
player={player}
style={styles.video}
nativeControls
/>
);
}
const styles = StyleSheet.create({
video: {
width: '100%',
aspectRatio: 16 / 9,
},
});Building a robust video player requires handling multiple states: loading, playing, paused, buffering, and errors. The expo-video library uses an event-based system with hooks from the expo package.
import React from 'react';
import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';
import { useEvent } from 'expo';
import { useVideoPlayer, VideoView } from 'expo-video';
interface VideoPlayerProps {
playbackId: string;
}
export default function VideoPlayer({ playbackId }: VideoPlayerProps) {
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.loop = false;
player.play();
}
);
const { status, error } = useEvent(player, 'statusChange', {
status: player.status,
});
const { isPlaying } = useEvent(player, 'playingChange', {
isPlaying: player.playing,
});
if (status === 'error') {
return (
<View style={styles.container}>
<Text style={styles.errorText}>
{error?.message || 'Failed to load video. Please try again.'}
</Text>
</View>
);
}
return (
<View style={styles.container}>
{status === 'loading' && (
<ActivityIndicator
size="large"
color="#fff"
style={styles.loader}
/>
)}
<VideoView
player={player}
style={styles.video}
nativeControls
contentFit="contain"
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'relative',
backgroundColor: '#000',
},
video: {
width: '100%',
aspectRatio: 16 / 9,
},
loader: {
position: 'absolute',
top: '50%',
left: '50%',
marginLeft: -20,
marginTop: -20,
zIndex: 10,
},
errorText: {
color: '#fff',
textAlign: 'center',
padding: 20,
},
});The expo-video player emits events that you can listen to using hooks from the expo package:
useEvent hookCreates a listener that returns a stateful value for use in components:
import { useEvent } from 'expo';
const { status, error } = useEvent(player, 'statusChange', {
status: player.status,
});
const { isPlaying } = useEvent(player, 'playingChange', {
isPlaying: player.playing,
});useEventListener hookFor side effects when events occur:
import { useEventListener } from 'expo';
useEventListener(player, 'statusChange', ({ status, error }) => {
console.log('Player status changed:', status);
if (error) {
console.error('Player error:', error);
}
});
useEventListener(player, 'playToEnd', () => {
console.log('Video finished playing');
player.replay();
});| Event | When it fires | Use case |
|---|---|---|
statusChange | Player status changes (idle, loading, readyToPlay, error) | Show loading states, handle errors |
playingChange | Play/pause state changes | Update play/pause button |
timeUpdate | Periodically during playback | Update progress bar |
sourceLoad | Video source finishes loading | Get duration, available tracks |
playToEnd | Video finishes playing | Auto-play next video, show replay |
Mux automatically generates thumbnails for your videos. Display a poster image that users tap to start playback:
import React, { useState } from 'react';
import { View, Image, Pressable, StyleSheet } from 'react-native';
import { useVideoPlayer, VideoView } from 'expo-video';
export default function VideoWithPoster({ playbackId }: { playbackId: string }) {
const [showPoster, setShowPoster] = useState(true);
const posterUrl = `https://image.mux.com/${playbackId}/thumbnail.png?time=0`;
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.loop = false;
// Don't autoplay - wait for user to tap poster
}
);
const handlePosterPress = () => {
setShowPoster(false);
player.play();
};
return (
<View style={styles.container}>
<VideoView
player={player}
style={styles.video}
nativeControls
contentFit="contain"
/>
{showPoster && (
<Pressable onPress={handlePosterPress} style={styles.poster}>
<Image
source={{ uri: posterUrl }}
style={styles.poster}
resizeMode="cover"
/>
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'relative',
},
video: {
width: '100%',
aspectRatio: 16 / 9,
},
poster: {
position: 'absolute',
width: '100%',
aspectRatio: 16 / 9,
},
});https://image.mux.com/{PLAYBACK_ID}/thumbnail.{format}?{params}Common parameters:
time - Timestamp in seconds (e.g., time=5 for 5 seconds in)width - Thumbnail width in pixels (e.g., width=640)height - Thumbnail height in pixels (e.g., height=360)fit_mode - How to resize: preserve, stretch, crop, smartcropExample:
const thumbnail = `https://image.mux.com/${playbackId}/thumbnail.jpg?time=5&width=1280&fit_mode=smartcrop`;Learn more about thumbnail options and image transformations in the main docs.
Choose the right aspect ratio based on your app's design:
Standard for most video content:
const styles = StyleSheet.create({
video: {
width: '100%',
aspectRatio: 16 / 9, // 1.777
},
});For Stories, Reels, or TikTok-style feeds:
const styles = StyleSheet.create({
video: {
width: '100%',
aspectRatio: 9 / 16, // 0.5625
},
});For social feeds:
const styles = StyleSheet.create({
video: {
width: '100%',
aspectRatio: 1, // 1.0
},
});Match the video's actual dimensions using the sourceLoad event:
import { View, StyleSheet } from 'react-native';
import { useEvent } from 'expo';
import { useVideoPlayer, VideoView } from 'expo-video';
export default function DynamicVideoPlayer({ playbackId = "TPsqaPkOFCKQHVGQ00Khp0256fLo4FAsEHjCTeWi02JyrM" }: { playbackId: string }) {
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.play();
}
);
const loadedMetadata = useEvent(player, 'sourceLoad');
// Calculate aspect ratio from available video tracks
const aspectRatio = (() => {
const tracks = loadedMetadata?.availableVideoTracks;
if (tracks && tracks.length > 0) {
const { width, height } = tracks[0].size;
return width / height;
}
return 16 / 9; // Default fallback
})();
return (
<VideoView
player={player}
style={[styles.video, { aspectRatio }]}
nativeControls
/>
);
}
const styles = StyleSheet.create({
video: {
width: '100%',
},
});The sourceLoad event and video track metadata work reliably on iOS and Android. On web, this event may not fire consistently. If you need dynamic aspect ratios across all platforms, consider fetching video dimensions from the Mux API or storing them alongside your playback ID.
Enable fullscreen mode using the VideoView ref methods:
import React, { useRef } from 'react';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { useVideoPlayer, VideoView } from 'expo-video';
export default function VideoPlayerWithFullscreen({ playbackId }: { playbackId: string }) {
const videoRef = useRef<VideoView>(null);
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.play();
}
);
const enterFullscreen = async () => {
await videoRef.current?.enterFullscreen();
};
const exitFullscreen = async () => {
await videoRef.current?.exitFullscreen();
};
return (
<View>
<VideoView
ref={videoRef}
player={player}
style={styles.video}
nativeControls={false}
allowsFullscreen
onFullscreenEnter={() => console.log('Entered fullscreen')}
onFullscreenExit={() => console.log('Exited fullscreen')}
/>
<TouchableOpacity onPress={enterFullscreen} style={styles.button}>
<Text style={styles.buttonText}>Go big or go home</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
video: {
width: '100%',
aspectRatio: 16 / 9,
},
button: {
backgroundColor: '#ec9430ff',
padding: 12,
borderRadius: 8,
marginTop: 10,
alignItems: 'center',
},
buttonText: {
color: '#fff',
fontWeight: 'bold',
},
});Fullscreen behavior is handled natively by the platform. On iOS, this uses AVPlayerViewController. On Android, this uses ExoPlayer's fullscreen controller.
Network issues are common on mobile. Implement robust error handling:
import React, { useState, useCallback } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useEvent } from 'expo';
import { useVideoPlayer, VideoView } from 'expo-video';
function VideoPlayerWithRetry({ playbackId }: { playbackId: string }) {
const [retryKey, setRetryKey] = useState(0);
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.play();
}
);
const { status, error } = useEvent(player, 'statusChange', {
status: player.status,
});
const retry = useCallback(() => {
player.replay();
setRetryKey(prev => prev + 1);
}, [player]);
const getErrorMessage = (error: any) => {
// Categorize errors based on the error message
const message = error?.message || '';
if (message.includes('network') || message.includes('ENOTFOUND')) {
return 'Network error. Check your connection.';
} else if (message.includes('403') || message.includes('forbidden')) {
return 'This video is not available.';
}
return 'Failed to load video.';
};
if (status === 'error') {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{getErrorMessage(error)}</Text>
<TouchableOpacity style={styles.retryButton} onPress={retry}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
return (
<VideoView
key={retryKey}
player={player}
style={styles.video}
nativeControls
/>
);
}
const styles = StyleSheet.create({
video: {
width: '100%',
aspectRatio: 16 / 9,
},
errorContainer: {
backgroundColor: '#000',
padding: 40,
alignItems: 'center',
justifyContent: 'center',
aspectRatio: 16 / 9,
},
errorText: {
color: '#fff',
fontSize: 16,
textAlign: 'center',
marginBottom: 20,
},
retryButton: {
backgroundColor: '#fff',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 5,
},
retryText: {
color: '#000',
fontWeight: 'bold',
},
});| Error | Cause | Solution |
|---|---|---|
| Network timeout | Slow/no connection | Show retry button, check network status |
| 403 Forbidden | Invalid playback ID or signed URL expired | Refresh token, verify playback ID |
| Video not loading | Asset still processing | Check asset status, show "processing" message |
| Playback stalled | Poor network | HLS handles this automatically via ABR |
Build custom video controls by setting nativeControls={false} and tracking playback state with events. This example creates a control bar with a play/pause button, scrubbing slider, and current/total time displays using @react-native-community/slider:
import React from 'react';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { useEvent } from 'expo';
import { useVideoPlayer, VideoView } from 'expo-video';
import Slider from '@react-native-community/slider';
export default function CustomControlsPlayer({ playbackId }: { playbackId: string }) {
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.timeUpdateEventInterval = 0.25; // Update every 250ms
}
);
const { isPlaying } = useEvent(player, 'playingChange', {
isPlaying: player.playing,
});
const timeUpdate = useEvent(player, 'timeUpdate');
const currentTime = timeUpdate?.currentTime ?? 0;
const duration = player.duration;
const handleSeek = (time: number) => {
player.currentTime = time;
};
const togglePlayback = () => {
if (isPlaying) {
player.pause();
} else {
player.play();
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<View style={styles.container}>
<VideoView
player={player}
style={styles.video}
nativeControls={false}
contentFit="contain"
/>
<View style={styles.controls}>
<TouchableOpacity onPress={togglePlayback}>
<Text style={styles.controlText}>
{isPlaying ? '⏸' : '▶'}
</Text>
</TouchableOpacity>
<Text style={styles.time}>{formatTime(currentTime)}</Text>
<Slider
style={styles.slider}
value={currentTime}
minimumValue={0}
maximumValue={duration || 1}
onSlidingComplete={handleSeek}
minimumTrackTintColor="#fff"
maximumTrackTintColor="#666"
thumbTintColor="#fff"
/>
<Text style={styles.time}>{formatTime(duration || 0)}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#000',
},
video: {
width: '100%',
aspectRatio: 16 / 9,
},
controls: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
backgroundColor: 'rgba(0,0,0,0.7)',
},
controlText: {
color: '#fff',
fontSize: 24,
marginRight: 10,
},
time: {
color: '#fff',
fontSize: 12,
marginHorizontal: 5,
},
slider: {
flex: 1,
marginHorizontal: 10,
},
});Allow users to adjust playback speed for faster or slower viewing:
import React, { useState, useCallback } from 'react';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { useVideoPlayer, VideoView } from 'expo-video';
const PLAYBACK_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
export default function VideoPlayerWithSpeed({ playbackId }: { playbackId: string }) {
const [speedIndex, setSpeedIndex] = useState(2); // Default to 1x
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.play();
}
);
const cycleSpeed = useCallback(() => {
const nextIndex = (speedIndex + 1) % PLAYBACK_SPEEDS.length;
setSpeedIndex(nextIndex);
player.playbackRate = PLAYBACK_SPEEDS[nextIndex];
}, [player, speedIndex]);
return (
<View style={styles.container}>
<VideoView
player={player}
style={styles.video}
nativeControls
contentFit="contain"
/>
<TouchableOpacity onPress={cycleSpeed} style={styles.speedButton}>
<Text style={styles.speedText}>
Speed: {PLAYBACK_SPEEDS[speedIndex]}x
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#000',
},
video: {
width: '100%',
aspectRatio: 16 / 9,
},
speedButton: {
backgroundColor: '#ec9430ff',
padding: 12,
margin: 10,
borderRadius: 8,
alignItems: 'center',
},
speedText: {
color: '#fff',
fontSize: 14,
fontWeight: 'bold',
},
});Tip: Use player.preservesPitch = true (default) to maintain audio pitch at higher speeds, or set to false for a "chipmunk" effect.
Enable Picture-in-Picture mode for background video playback:
import React, { useRef, useState, useCallback } from 'react';
import { View, TouchableOpacity, Text, StyleSheet, Platform } from 'react-native';
import { useVideoPlayer, VideoView, isPictureInPictureSupported } from 'expo-video';
export default function VideoPlayerWithPiP({ playbackId }: { playbackId: string }) {
const videoRef = useRef<VideoView>(null);
const [isInPiP, setIsInPiP] = useState(false);
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.play();
}
);
const togglePiP = useCallback(() => {
if (!isInPiP) {
videoRef.current?.startPictureInPicture();
} else {
videoRef.current?.stopPictureInPicture();
}
}, [isInPiP]);
// Check PiP support (function only exists on iOS and Android)
const pipSupported = Platform.OS !== 'web' && isPictureInPictureSupported();
if (!pipSupported) {
return (
<View style={styles.container}>
<Text style={styles.errorText}>
Picture-in-Picture is not supported on this platform.
</Text>
</View>
);
}
return (
<View style={styles.container}>
<VideoView
ref={videoRef}
player={player}
style={styles.video}
nativeControls
allowsPictureInPicture
startsPictureInPictureAutomatically
onPictureInPictureStart={() => setIsInPiP(true)}
onPictureInPictureStop={() => setIsInPiP(false)}
/>
<TouchableOpacity onPress={togglePiP} style={styles.button}>
<Text style={styles.buttonText}>
{isInPiP ? 'Exit' : 'Enter'} Picture-in-Picture
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#000',
},
video: {
width: '100%',
aspectRatio: 16 / 9,
},
button: {
backgroundColor: '#ec9430ff',
padding: 12,
margin: 10,
borderRadius: 8,
alignItems: 'center',
},
buttonText: {
color: '#fff',
fontWeight: 'bold',
},
errorText: {
color: '#fff',
textAlign: 'center',
padding: 20,
},
});Picture-in-Picture requires configuration in your app.json:
{
"expo": {
"plugins": [
["expo-video", { "supportsPictureInPicture": true }]
]
}
}While expo-video abstracts most platform differences, be aware of:
allowsExternalPlaybackTest your video player on both iOS and Android physical devices, not just simulators. Network behavior and video codecs can differ between simulators and real devices.
Optimize playback for each platform by detecting the OS and adjusting player settings. This example shows iOS-specific AirPlay support, platform-specific buffer configurations, and Android's TextureView for overlapping videos:
import { Platform } from 'react-native';
import { useVideoPlayer, VideoView } from 'expo-video';
export default function PlatformAwarePlayer({ playbackId }: { playbackId: string }) {
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.play();
// iOS-specific settings
if (Platform.OS === 'ios') {
player.allowsExternalPlayback = true; // Enable AirPlay
}
// Configure buffer options
player.bufferOptions = {
preferredForwardBufferDuration: Platform.OS === 'ios' ? 0 : 20,
minBufferForPlayback: 2,
};
}
);
return (
<VideoView
player={player}
style={{ width: '100%', aspectRatio: 16 / 9 }}
nativeControls
// Android-specific: use TextureView for overlapping videos
surfaceType={Platform.OS === 'android' ? 'textureView' : undefined}
/>
);
}import { useEffect } from 'react';
import { AppState } from 'react-native';
import { useVideoPlayer, VideoView } from 'expo-video';
export default function VideoPlayer({ playbackId }: { playbackId: string }) {
const player = useVideoPlayer(
`https://stream.mux.com/${playbackId}.m3u8`,
player => {
player.staysActiveInBackground = false;
}
);
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
player.play();
} else {
player.pause();
}
});
return () => subscription.remove();
}, [player]);
return (
<VideoView
player={player}
style={{ width: '100%', aspectRatio: 16 / 9 }}
nativeControls
/>
);
}import { useVideoPlayer, VideoView, VideoSource } from 'expo-video';
import { useState, useCallback } from 'react';
import { TouchableOpacity, Text, View, StyleSheet } from 'react-native';
const video1: VideoSource = 'https://stream.mux.com/PLAYBACK_ID_1.m3u8';
const video2: VideoSource = 'https://stream.mux.com/PLAYBACK_ID_2.m3u8';
export default function PreloadingPlayer() {
// Create both players - the second one preloads in the background
const player1 = useVideoPlayer(video1, player => {
player.play();
});
const player2 = useVideoPlayer(video2, player => {
player.currentTime = 0; // Preload from the start
});
const [currentPlayer, setCurrentPlayer] = useState(player1);
const switchVideo = useCallback(() => {
currentPlayer.pause();
if (currentPlayer === player1) {
setCurrentPlayer(player2);
player2.play();
} else {
setCurrentPlayer(player1);
player1.play();
}
}, [currentPlayer, player1, player2]);
return (
<View>
<VideoView player={currentPlayer} style={styles.video} nativeControls />
<TouchableOpacity onPress={switchVideo} style={styles.button}>
<Text style={styles.buttonText}>Switch Video</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
video: {
width: '100%',
aspectRatio: 16 / 9,
},
button: {
backgroundColor: '#4630ec',
padding: 12,
borderRadius: 8,
marginTop: 10,
alignItems: 'center',
},
buttonText: {
color: '#fff',
fontWeight: 'bold',
},
});If your app frequently replays the same videos, enable caching to minimize network usage and improve playback performance. The cache is persistent and managed on a least-recently-used (LRU) basis:
import { useVideoPlayer, VideoView, VideoSource } from 'expo-video';
function CachedVideoPlayer({ playbackId }: { playbackId: string }) {
const videoSource: VideoSource = {
uri: `https://stream.mux.com/${playbackId}.m3u8`,
useCaching: true,
metadata: {
title: 'My Video',
},
};
const player = useVideoPlayer(videoSource, player => {
player.play();
});
return (
<VideoView
player={player}
style={{ width: '100%', aspectRatio: 16 / 9 }}
nativeControls
/>
);
}How caching works:
Managing the cache:
import {
setVideoCacheSizeAsync,
getCurrentVideoCacheSize,
clearVideoCacheAsync
} from 'expo-video';
// Set cache size to 500MB (must be called when no players exist)
await setVideoCacheSizeAsync(500 * 1024 * 1024);
// Get current cache size
const cacheSize = getCurrentVideoCacheSize();
console.log(`Cache is using ${cacheSize} bytes`);
// Clear all cached videos (must be called when no players exist)
await clearVideoCacheAsync();Caching limitations:
VideoPlayer instances existUse lower resolution thumbnails for poster images to reduce initial load time:
const posterUrl = `https://image.mux.com/${playbackId}/thumbnail.jpg?width=640&time=0`;This guide covers the most common video playback patterns with Mux. The expo-video library offers many additional capabilities beyond what's covered here.
For advanced features and patterns, see the official expo-video examples repository:
seekBy() / replay() methodsMux supports most of these features natively. For example, Mux can automatically generate subtitles, provide DRM protection, and deliver multiple audio tracks. Learn more in the Video features documentation.