React Native video player with disk caching, preloading, and multi-URI support. Published on npm under the @orca-runtime scope. Built on Nitro Modules.
npm install @orca-runtime/orca-video-player react-native-nitro-modulesyarn add @orca-runtime/orca-video-player react-native-nitro-modulespnpm add @orca-runtime/orca-video-player react-native-nitro-modules
react-native-nitro-modulesis a required peer dependency.
After installing or changing native code, run:
yarn nitrogen # first time / after *.nitro.ts changes
cd ios && pod installAll public APIs are imported from @orca-runtime/orca-video-player:
import {
OrcaVideoPlayer,
OrcaVideoPlayerCacheApi,
useVideoCache,
getVideoUris,
isRemoteVideoUri,
resolveVideoSource,
resolveVideoUri,
} from '@orca-runtime/orca-video-player';
import type {
OrcaVideoPlayerProps,
OrcaVideoPlayerHandle,
VideoSource,
VideoUri,
VideoSize,
ResizeMode,
ResolvedVideoSource,
UseVideoCacheOptions,
UseVideoCacheResult,
} from '@orca-runtime/orca-video-player';import { OrcaVideoPlayer } from '@orca-runtime/orca-video-player';
<OrcaVideoPlayer
source={{ uri: 'https://example.com/video.mp4' }}
autoplay
muted
controls
resizeMode="cover"
onProgress={(time) => console.log(time)}
onEnd={() => console.log('ended')}
/>;Replay the same source indefinitely in a single player instance. When loop is enabled, onEnd is not called:
<OrcaVideoPlayer
source={{ uri: 'https://example.com/video.mp4' }}
loop
autoplay
muted
/>Attach a ref to control playback from your own UI — useful when controls={false} and you need custom Play / Pause / Seek buttons:
import { useRef } from 'react';
import { Pressable, Text } from 'react-native';
import {
OrcaVideoPlayer,
type OrcaVideoPlayerHandle,
} from '@orca-runtime/orca-video-player';
const playerRef = useRef<OrcaVideoPlayerHandle>(null);
<>
<OrcaVideoPlayer
ref={playerRef}
source={{ uri: 'https://example.com/video.mp4' }}
controls={false}
resizeMode="cover"
/>
<Pressable onPress={() => playerRef.current?.play()}>
<Text>Play</Text>
</Pressable>
<Pressable onPress={() => playerRef.current?.pause()}>
<Text>Pause</Text>
</Pressable>
<Pressable onPress={() => playerRef.current?.seekTo(10)}>
<Text>Seek to 10s</Text>
</Pressable>
<Pressable onPress={() => playerRef.current?.enterPictureInPicture()}>
<Text>PiP</Text>
</Pressable>
</>;| Method | Description |
|---|---|
play() |
Start or resume playback |
pause() |
Pause playback |
seekTo(seconds) |
Seek to a position in seconds |
enterPictureInPicture() |
Enter Picture-in-Picture mode |
exitPictureInPicture() |
Exit Picture-in-Picture mode |
interface OrcaVideoPlayerHandle {
play(): void;
pause(): void;
seekTo(seconds: number): void;
enterPictureInPicture(): void;
exitPictureInPicture(): void;
}Ref methods are available on iOS, Android, and web. On web,
play()may be blocked by the browser until the user interacts with the page.
Enable PiP on a player and optionally enter it automatically when the app moves to the background:
const playerRef = useRef<OrcaVideoPlayerHandle>(null);
<OrcaVideoPlayer
ref={playerRef}
source={{ uri: 'https://example.com/video.mp4' }}
allowsPictureInPicture
autoEnterPictureInPicture
onPictureInPictureChange={(active) => console.log('PiP active:', active)}
/>
<Pressable onPress={() => playerRef.current?.enterPictureInPicture()}>
<Text>Enter PiP</Text>
</Pressable>iOS — add background audio mode to your app's Info.plist:
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>When controls={true} and allowsPictureInPicture is enabled, the system controls may also show a PiP button.
Android — PiP requires host app configuration:
- Enable PiP on your main activity in
AndroidManifest.xml:
<activity
android:name=".MainActivity"
android:supportsPictureInPicture="true"
...
/>- Forward lifecycle hooks from
MainActivity:
import com.margelo.nitro.orcavideoplayer.OrcaVideoPlayerPipHelper
override fun onUserLeaveHint() {
OrcaVideoPlayerPipHelper.onUserLeaveHint(this)
super.onUserLeaveHint()
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
OrcaVideoPlayerPipHelper.onPictureInPictureModeChanged(isInPictureInPictureMode)
}autoEnterPictureInPicture uses onUserLeaveHint to enter PiP when the user leaves the app while video is playing.
Web — uses the Document Picture-in-Picture API. Auto PiP on tab hide works only in browsers that support it; otherwise it is a no-op. User interaction may be required before enterPictureInPicture() succeeds.
PiP is best tested on a physical iOS device. The iOS Simulator has limited PiP support.
| Prop | Type | Default | Description |
|---|---|---|---|
source |
VideoSource | number |
required | Video source (URI object or bundled require() asset id) |
uriIndex |
number |
0 |
Index into source.uri when it is an array |
autoplay |
boolean |
false |
Start playback automatically |
muted |
boolean |
false |
Mute audio |
controls |
boolean |
false |
Show native playback controls |
resizeMode |
'cover' | 'contain' | 'stretch' |
'contain' |
How video fills the view |
preload |
boolean |
false |
Buffer the video in the player without starting playback (unless autoplay is also set) |
lowMemory |
boolean |
false |
Minimize buffer/decoder memory for concurrent playback (see Low-memory mode) |
loop |
boolean |
false |
Replay the same source indefinitely. When true, onEnd is not called |
allowsPictureInPicture |
boolean |
false |
Enable Picture-in-Picture support |
autoEnterPictureInPicture |
boolean |
false |
Enter PiP automatically when the app moves to the background (requires platform setup) |
width |
number |
— | Container width (merged into style, overrides style.width) |
height |
number |
— | Container height (merged into style, overrides style.height) |
onProgress |
(time: number) => void |
— | Called with current time in seconds |
onEnd |
() => void |
— | Called when playback finishes (not called when loop is true) |
onPictureInPictureChange |
(active: boolean) => void |
— | Called when PiP mode starts or stops |
onVideoSizeChange |
(size: VideoSize) => void |
— | Called with natural video dimensions as soon as metadata is loaded |
style |
StyleProp<ViewStyle> |
— | Container style |
onVideoSizeChange fires when the video's natural dimensions become available (on load), without waiting for playback to start. It may fire again if the resolution changes (for example adaptive streams).
<OrcaVideoPlayer
source={{ uri: 'https://example.com/video.mp4' }}
width={360}
height={220}
onVideoSizeChange={({ width, height }) => {
console.log('natural size', width, height);
}}
/>interface VideoSize {
width: number;
height: number;
}type VideoUri = string | number;
interface VideoSource {
uri: VideoUri | VideoUri[];
headers?: Record<string, string>;
type?: string;
}uri accepts a remote URL string or a Metro asset id from require('./video.mp4'). You can also pass the asset id directly as source:
// Bundled MP4 via require
<OrcaVideoPlayer source={{ uri: require('./video.mp4') }} autoplay muted />
// Equivalent bare asset source
<OrcaVideoPlayer source={require('./video.mp4')} autoplay muted />Bundled / file:// URIs are played as-is. Disk cache preload only runs for http:// and https:// URLs.
Pass an array of remote URLs and select which one to play with uriIndex:
import { useState } from 'react';
import { OrcaVideoPlayer } from '@orca-runtime/orca-video-player';
const VIDEOS = [
'https://example.com/video-a.mp4',
'https://example.com/video-b.mp4',
];
const [index, setIndex] = useState(0);
<OrcaVideoPlayer
source={{ uri: VIDEOS }}
uriIndex={index}
controls
resizeMode="cover"
/>;There are two different preload mechanisms:
| Mechanism | API | Scope | Use case |
|---|---|---|---|
| Player preload | OrcaVideoPlayer preload prop |
In-memory, tied to mounted player | Buffer early on the same screen |
| Disk cache | OrcaVideoPlayerCacheApi / useVideoCache |
Persistent on device, global | Preload at app start, play on another screen |
Prepares the native player and buffers media without starting playback:
<OrcaVideoPlayer
source={{ uri: 'https://example.com/video.mp4' }}
preload
autoplay={false}
controls
/>When the player unmounts, this buffer is released. For cross-screen preloading, use the disk cache API below.
<OrcaVideoPlayer
source={{ uri: 'https://example.com/video.mp4' }}
preload
autoplay={false}
controls
/>
<OrcaVideoPlayer
source={{ uri: 'https://example.com/video.mp4' }}
preload={false}
autoplay={false}
controls
/>;Use lowMemory when several players may be mounted at once (feeds, grids). It does not re-encode video or switch to a lower-resolution URI — progressive files still decode at their encoded resolution. Instead it reduces how much each player keeps buffered and, on Android, how many players may hold an active decoder at the same time.
<OrcaVideoPlayer
source={{ uri: 'https://example.com/video.mp4' }}
lowMemory
autoplay
muted
loop
/>| Platform | Behavior when lowMemory is enabled |
|---|---|
| Android | Small ExoPlayer LoadControl buffers; at most 2 active prepared players; others stay idle until play() / autoplay / preload |
| iOS | Short preferredForwardBufferDuration, lower preferredPeakBitRate, view-based preferredMaximumResolution |
| Web | Uses preload="metadata" when not autoplaying |
Feed tips:
- Set
lowMemoryon list/grid cells. - Prefer
pause()or unmount for off-screen cells so Android can free decoder slots. - For true lower visual quality you still need a smaller source URI (or adaptive streams); this mode only caps buffering and concurrency.
OrcaVideoPlayerCacheApi is a global native singleton. It downloads videos to the device cache directory and resolves them to a local file:// URI.
import { OrcaVideoPlayerCacheApi } from '@orca-runtime/orca-video-player';
// Download one video
await OrcaVideoPlayerCacheApi.preload({
uri: 'https://example.com/video.mp4',
});
// Download multiple videos
await OrcaVideoPlayerCacheApi.preload({
uri: ['https://example.com/video-a.mp4', 'https://example.com/video-b.mp4'],
});
// Check cache status
OrcaVideoPlayerCacheApi.isCached('https://example.com/video.mp4'); // boolean
// Get local file URI (null if not cached yet)
const localUri = OrcaVideoPlayerCacheApi.getCachedUri(
'https://example.com/video.mp4'
);
// Clear cache
await OrcaVideoPlayerCacheApi.clearCache('https://example.com/video.mp4');
await OrcaVideoPlayerCacheApi.clearAllCache();const remoteUri = 'https://example.com/video.mp4';
const cachedUri = OrcaVideoPlayerCacheApi.getCachedUri(remoteUri);
<OrcaVideoPlayer source={{ uri: cachedUri ?? remoteUri }} autoplay controls />;React hook that wraps the disk cache API and returns a resolved source for OrcaVideoPlayer.
import {
OrcaVideoPlayer,
useVideoCache,
} from '@orca-runtime/orca-video-player';
const { source, isCached, isPreloading, preload, clearCache } = useVideoCache(
{ uri: 'https://example.com/video.mp4' },
{
autoPreload: false, // download on mount
preferCache: true, // use local file URI when available
uriIndex: 0, // active URI when uri is an array
}
);
<OrcaVideoPlayer source={source} controls />;| Option | Type | Default | Description |
|---|---|---|---|
autoPreload |
boolean |
false |
Download to disk when the hook mounts |
preferCache |
boolean |
true |
Resolve source.uri to a cached local URI |
uriIndex |
number |
0 |
Active URI when source.uri is an array |
| Field | Type | Description |
|---|---|---|
source |
VideoSource |
Resolved source (local URI when cached) |
uris |
string[] |
All remote URIs |
activeUri |
string |
Currently selected remote URI |
isCached |
boolean |
true when all URIs are cached |
cachedByUri |
Record<string, boolean> |
Per-URI cache status |
isPreloading |
boolean |
Download in progress |
error |
Error | null |
Last preload error |
preload |
() => Promise<void> |
Manually trigger download |
clearCache |
() => Promise<void> |
Clear cache for all URIs in the source |
import { useState } from 'react';
import {
OrcaVideoPlayer,
useVideoCache,
} from '@orca-runtime/orca-video-player';
const [uriIndex, setUriIndex] = useState(0);
const { source, cachedByUri, preload } = useVideoCache(
{ uri: ['https://example.com/a.mp4', 'https://example.com/b.mp4'] },
{ uriIndex, preferCache: true }
);
// cachedByUri['https://example.com/a.mp4'] → boolean
<OrcaVideoPlayer source={source} uriIndex={uriIndex} controls />;const { source } = useVideoCache(
{ uri: 'https://example.com/video.mp4' },
{ autoPreload: true, preferCache: true }
);Recommended pattern for feed / detail flows:
// Root layout / app bootstrap
import { useEffect } from 'react';
import { OrcaVideoPlayerCacheApi } from '@orca-runtime/orca-video-player';
const FEED_VIDEOS = [
'https://example.com/video-a.mp4',
'https://example.com/video-b.mp4',
];
export function AppBootstrap() {
useEffect(() => {
OrcaVideoPlayerCacheApi.preload({ uri: FEED_VIDEOS });
}, []);
return <Navigation />;
}// Player screen (different route)
import {
OrcaVideoPlayer,
useVideoCache,
} from '@orca-runtime/orca-video-player';
export function PlayerScreen({ remoteUri }: { remoteUri: string }) {
const { source } = useVideoCache({ uri: remoteUri }, { preferCache: true });
return <OrcaVideoPlayer source={source} autoplay controls />;
}If the user opens the player before preload finishes,
preferCachefalls back to the remote URL automatically.
import {
getVideoUris,
isRemoteVideoUri,
resolveVideoSource,
resolveVideoUri,
} from '@orca-runtime/orca-video-player';
getVideoUris(['https://a.mp4', 'https://b.mp4']);
// → ['https://a.mp4', 'https://b.mp4']
getVideoUris('https://a.mp4');
// → ['https://a.mp4']
resolveVideoSource({ uri: ['https://a.mp4', 'https://b.mp4'] }, 1);
// → { uri: 'https://b.mp4' }
resolveVideoSource({ uri: require('./video.mp4') });
// → { uri: 'file:///.../video.mp4' } (or Metro asset HTTP URL in dev)
resolveVideoSource(require('./video.mp4'));
// → { uri: 'file:///.../video.mp4' }
isRemoteVideoUri('https://a.mp4'); // true
isRemoteVideoUri('file:///path/video.mp4'); // false- iOS: Cache stored in
Caches/orca-video-cache/. Player preload usesAVPlayerbuffering.lowMemoryshortens forward buffer and applies peak bitrate / max resolution hints. - Android: Cache stored in
cacheDir/orca-video-cache/. Player preload uses ExoPlayerprepare().lowMemoryuses a smallLoadControland caps active players at 2. - Web: Cache uses in-memory blob URLs (not persisted across page reloads).
lowMemorypreferspreload="metadata". - Cache may be evicted by the OS at any time. Always fall back to the remote URI.
- Authenticated URLs (
headers) are not yet supported by the native disk cache downloader.
If you find this library useful, you can support Orca Runtime on Patreon.
MIT
Made with create-react-native-library

