Advanced playback in React Native

Add posters, thumbnails, timeline hovers, captions, audio tracks, playback speed, and Picture-in-Picture to a React Native video player built on expo-video and FastPix.

The quickstart gets a FastPix video playing with native controls. This guide adds the features real apps need: poster images, timeline hover previews, captions and audio tracks, playback speed, volume, fullscreen, and Picture-in-Picture.

Two systems do the work. FastPix image URLs give you posters and timeline sprites straight from a playback ID, with no extra API calls. expo-video exposes tracks, speed, volume, and Picture-in-Picture on the player and view you already have.

Every section extends the quickstart’s VideoPlayer component.

Each code block is a complete file. The bold filename above it tells you where the code goes. Create or replace that file with the code shown. The individual controls later on (speed, captions, and audio) are complete snippets you drop into the enhanced player from the first section, and each one says exactly where it goes.


Before you begin

Make sure you have the following:

  • You’ve completed the quickstart, so you have a working VideoPlayer component with expo-video installed.
  • A FastPix playback ID. The quickstart’s sample ID carries multiple audio and subtitle tracks, which makes it good for testing track switching.

The enhanced player also renders the poster with expo-image, which the quickstart doesn’t install. Add it now:

npx expo install expo-image

Poster images and thumbnails

FastPix serves a still frame for any playback ID from the image host, with no upload or extra request needed:

https://images.fastpix.com/{PLAYBACK_ID}/thumbnail.jpg

Query parameters control the frame and size:

ParameterTypeDescription
timeintegerFrame to capture, in seconds from the start.
widthintegerOutput width in pixels.
heightintegerOutput height in pixels.
fit_modestringpreserve, stretch, crop, intellicrop, or pad.
tokenstringJWT, required only for private (signed) playback.

The image is also available as .png or .webp. First, replace your fastpix.ts helper with this version, which adds getThumbnailUrl alongside the getStreamUrl from the quickstart:

src/constants/fastpix.ts

const STREAM_HOST = "https://stream.fastpix.com";
const IMAGE_HOST = "https://images.fastpix.com";
/** HLS stream URL for a FastPix playback ID, ready to hand to expo-video. */
export function getStreamUrl(playbackId: string): string {
return `${STREAM_HOST}/${playbackId}.m3u8`;
}
export type ThumbnailOptions = {
readonly time?: number;
readonly width?: number;
readonly height?: number;
readonly fitMode?: "preserve" | "stretch" | "crop" | "intellicrop" | "pad";
};
/** Thumbnail image URL for a playback ID, for example for a poster frame. */
export function getThumbnailUrl(
playbackId: string,
options: ThumbnailOptions = {},
): string {
const params = new URLSearchParams();
if (options.time != null) params.set("time", String(options.time));
if (options.width != null) params.set("width", String(options.width));
if (options.height != null) params.set("height", String(options.height));
if (options.fitMode) params.set("fit_mode", options.fitMode);
const query = params.toString();
return `${IMAGE_HOST}/${playbackId}/thumbnail.jpg${query ? `?${query}` : ""}`;
}

Now replace the quickstart’s video-player.tsx with this enhanced player. It’s the base for everything below: it shows a FastPix thumbnail as a poster until playback starts, sets volume and mute, and renders loading and error states. expo-video doesn’t draw a poster itself, so you overlay an Image and hide it on the first playingChange. useEvent and useEventListener come from the expo package.

src/components/video-player.tsx

import { useEvent, useEventListener } from "expo";
import { Image } from "expo-image";
import { useVideoPlayer, VideoView } from "expo-video";
import { useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { getStreamUrl, getThumbnailUrl } from "../constants/fastpix";
type VideoPlayerProps = {
readonly playbackId: string;
readonly loop?: boolean;
readonly autoPlay?: boolean;
};
export default function VideoPlayer({
playbackId,
loop = false,
autoPlay = true,
}: VideoPlayerProps) {
const [showPoster, setShowPoster] = useState(true);
const player = useVideoPlayer(getStreamUrl(playbackId), (player) => {
player.loop = loop;
player.muted = false;
player.volume = 1.0; // 0.0 to 1.0; independent of muted
if (autoPlay) player.play();
});
// Re-renders whenever the player's status changes.
const { status, error } = useEvent(player, "statusChange", {
status: player.status,
});
// Hide the poster once real playback starts.
useEventListener(player, "playingChange", ({ isPlaying }) => {
if (isPlaying) setShowPoster(false);
});
return (
<View style={styles.container}>
<VideoView
player={player}
style={styles.video}
contentFit="contain"
allowsPictureInPicture
nativeControls
/>
{showPoster && (
<Image
source={getThumbnailUrl(playbackId, { time: 5, width: 1280 })}
style={styles.overlay}
contentFit="contain"
/>
)}
{status === "loading" && <ActivityIndicator style={styles.overlay} />}
{status === "error" && (
<Text style={styles.error}>{error?.message ?? "Playback error"}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#000",
},
video: { width: "100%", aspectRatio: 16 / 9 },
overlay: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 },
error: { position: "absolute", color: "#fff" },
});

volume and muted are independent: muting doesn’t change the volume value, so unmuting restores the previous level. For timeUpdate events (a { currentTime } payload), set player.timeUpdateEventInterval to a value in seconds in the setup callback. It’s 0 (off) by default.


Timeline hover previews (spritesheets)

FastPix generates a spritesheet, a grid of frames sampled across the video, plus metadata mapping each timecode to a tile position. It’s what powers the small preview image that follows your cursor along a scrub bar.

https://images.fastpix.com/{PLAYBACK_ID}/spritesheet.jpg
https://images.fastpix.com/{PLAYBACK_ID}/spritesheet.vtt?format=jpg # WebVTT tile map
https://images.fastpix.com/{PLAYBACK_ID}/spritesheet.json?format=jpg # JSON tile map

FastPix sizes the grid to the video: 50 tiles under 15 minutes, 100 tiles above.

The native controls include their own scrubber, so you only need this if you build a custom scrub bar. The approach: fetch the .json metadata (each entry gives an x, y, width, height, and time range), and as the user drags, render the spritesheet in a small clipped View offset to the tile whose time range contains the drag position.

NOTE:
A custom scrubber replaces the native controls (nativeControls={false}) and is a sizeable component in its own right. If you just want a poster or a paused-state image, the thumbnail URL above is simpler.


Volume and mute

player.muted and player.volume (0.0 to 1.0) are already set in the enhanced player’s setup callback above. Change them any time in response to UI, for example player.muted = true from a mute button. Muting doesn’t change the volume value, so unmuting restores the previous level.


Playback speed

playbackRate accepts values from 0 to 16 (1.0 is normal). Here’s a simple cycle button:

src/components/video-player.tsx

import { Button } from "react-native";
const SPEEDS = [1, 1.25, 1.5, 2];
// inside the component, after `player` is created:
const [speedIndex, setSpeedIndex] = useState(0);
function cycleSpeed() {
const next = (speedIndex + 1) % SPEEDS.length;
setSpeedIndex(next);
player.playbackRate = SPEEDS[next];
}
// in the JSX:
<Button title={`${SPEEDS[speedIndex]}x`} onPress={cycleSpeed} />

By default the player preserves audio pitch as speed changes (player.preservesPitch).


Subtitles and captions

The player exposes the tracks in the HLS manifest through availableSubtitleTracks, and you set the active one with subtitleTrack (or null to turn captions off). Each track has a label and language:

src/components/video-player.tsx

import { Button } from "react-native";
// The available tracks populate once the media loads.
const tracks = player.availableSubtitleTracks;
// Turn on the first available track:
player.subtitleTrack = tracks[0] ?? null;
// Render a picker:
<>
<Button title="Off" onPress={() => (player.subtitleTrack = null)} />
{tracks.map((track) => (
<Button
key={track.language}
title={track.label}
onPress={() => (player.subtitleTrack = track)}
/>
))}
</>

NOTE:
Always assign a track object taken from availableSubtitleTracks. Don’t construct one by hand. The list is empty until the media has loaded, so read it after playback starts (or from a statusChange listener) rather than during the first render.


Audio tracks

Multi-language audio works the same way, through availableAudioTracks and audioTrack:

src/components/video-player.tsx

const audioTracks = player.availableAudioTracks;
// Switch to a specific language:
const spanish = audioTracks.find((t) => t.language === "es");
if (spanish) player.audioTrack = spanish;

NOTE:
The sample asset in the quickstart carries several audio tracks (including Tamil, Hindi, and Telugu) and subtitles, so it’s a good one to test track switching against.


React to player events

The enhanced player already reacts to statusChange (driving the loading spinner and error text) through useEvent from the expo package. Two other events are worth knowing:

  • playingChange: payload { isPlaying }. The enhanced player uses it to hide the poster.
  • timeUpdate: payload { currentTime }. Useful for a custom progress readout. It only fires if you set player.timeUpdateEventInterval to a value in seconds (it’s 0, off, by default).

Use useEvent(player, name, initial) when a value should re-render the UI, and useEventListener(player, name, handler) for side effects that shouldn’t.


Fullscreen and Picture-in-Picture

The enhanced player passes nativeControls and allowsPictureInPicture, so the native controls already show fullscreen and Picture-in-Picture buttons. To trigger either one from your own button, keep a ref to the VideoView. This complete variant adds a ref and two buttons:

src/components/video-player.tsx

import { useEvent, useEventListener } from "expo";
import { Image } from "expo-image";
import { useVideoPlayer, VideoView } from "expo-video";
import { useRef, useState } from "react";
import { ActivityIndicator, Button, StyleSheet, View } from "react-native";
import { getStreamUrl, getThumbnailUrl } from "../constants/fastpix";
type VideoPlayerProps = {
readonly playbackId: string;
readonly autoPlay?: boolean;
};
export default function VideoPlayer({
playbackId,
autoPlay = true,
}: VideoPlayerProps) {
const viewRef = useRef<VideoView>(null);
const [showPoster, setShowPoster] = useState(true);
const player = useVideoPlayer(getStreamUrl(playbackId), (player) => {
if (autoPlay) player.play();
});
useEventListener(player, "playingChange", ({ isPlaying }) => {
if (isPlaying) setShowPoster(false);
});
return (
<View style={styles.container}>
<VideoView
ref={viewRef}
player={player}
style={styles.video}
contentFit="contain"
allowsPictureInPicture
startsPictureInPictureAutomatically
nativeControls
/>
{showPoster && (
<Image
source={getThumbnailUrl(playbackId, { time: 5, width: 1280 })}
style={StyleSheet.absoluteFill}
contentFit="contain"
/>
)}
<View style={styles.row}>
<Button
title="Fullscreen"
onPress={() => viewRef.current?.enterFullscreen()}
/>
<Button
title="PiP"
onPress={() =>
viewRef.current?.startPictureInPicture().catch(() => {
// Unsupported on the iOS Simulator and some devices; ignore.
})
}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: "center", backgroundColor: "#000" },
video: { width: "100%", aspectRatio: 16 / 9 },
row: { flexDirection: "row", justifyContent: "center", gap: 16, padding: 12 },
});

NOTE:
Picture-in-Picture requires native configuration and does not work in Expo Go. Build a development build with npx expo run:ios or npx expo run:android. On iOS it also needs the background modes enabled through the expo-video config plugin, and it is not supported on the iOS Simulator, so test Picture-in-Picture on a real device. startPictureInPicture() returns a promise that rejects when Picture-in-Picture is unavailable, so always .catch() it (as above) to avoid an unhandled rejection.


Play a segment of the video

To start or stop playback at specific timestamps, add start and end (in seconds) to the stream URL, with no code change beyond the URL:

https://stream.fastpix.com/{PLAYBACK_ID}.m3u8?start=30&end=90

You can also cap the delivered quality with max_resolution (for example, max_resolution=720p) to save bandwidth. See play your videos for the full set of playback URL parameters.


Frequently asked questions

Why doesn't Picture-in-Picture work in Expo Go or the iOS Simulator?

Picture-in-Picture needs native configuration, so it doesn’t run in Expo Go. Build a development build with npx expo run:ios or npx expo run:android, and enable background modes through the expo-video config plugin on iOS. Even in a development build, Picture-in-Picture isn’t supported on the iOS Simulator, so test it on a real device.

Why are `availableSubtitleTracks` or `availableAudioTracks` empty?

Both lists populate only after the media loads, so reading them during the first render returns an empty array. Read them after playback starts, or from a statusChange listener. If a list stays empty on a device where the tracks do exist, check your expo-video version, because some releases have known gaps in reporting these tracks on iOS.

Can I show a thumbnail for a private (signed) playback ID?

Yes. Add a token query parameter to the thumbnail URL with a JWT signed for that playback ID. Public playback needs no token. See Generate JWTs for secure media for how to sign one.


What’s next