Install the package:
npm install react-native-cross-player
# or
yarn add react-native-cross-playerInstall the required peer dependencies (required by the library at runtime):
npm install --save nativewind react react-native react-native-awesome-slider react-native-blob-util react-native-gesture-handler react-native-orientation-locker react-native-reanimated react-native-safe-area-context react-native-svg react-native-system-navigation-bar react-native-video react-native-worklets tailwindcss
# or with yarn
yarn add nativewind react react-native react-native-awesome-slider react-native-blob-util react-native-gesture-handler react-native-orientation-locker react-native-reanimated react-native-safe-area-context react-native-svg react-native-system-navigation-bar react-native-video react-native-worklets tailwindcssNotes:
- You do not need to install these in a library project if your app already provides them; they are peers and must be available in the consuming app.
This library uses NativeWind v4 for styling. You must set up NativeWind in your consuming application.
For Web: Import the CSS file in your global CSS or directly in your app:
/* Option 1: In your global.css or app.css */
@import 'react-native-cross-player/styles.css';Or import directly in your entry file (e.g., with Metro Web / Expo Web):
// In your App.tsx or _layout.tsx
import 'react-native-cross-player/styles.css';For Native (iOS/Android): The styles are applied via NativeWind's className processing. Make sure your babel.config.js includes:
module.exports = function (api) {
api.cache(true);
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins: ['@babel/plugin-proposal-export-namespace-from', 'react-native-reanimated/plugin'],
};
};Your Expo Metro config should pass the same global CSS entry to NativeWind:
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });Basic usage in a React Native app:
import React from 'react';
import { VideoPlayer } from 'react-native-cross-player';
export default function App() {
const playerId = 'demo-player';
const playerConfig = {
playerId,
videoSources: [
{
id: 'main',
playerId,
label: 'Main stream',
source: 'https://example.com/video.m3u8',
format: 'm3u8',
},
],
subtitleSources: [],
initialVideoSource: 0,
};
return (
<VideoPlayer
videoTitle="Demo"
playerConfig={playerConfig}
viewStyle={{ flex: 1 }}
theme={{
minimumTrackTintColor: '#0ea5e9',
maximumTrackTintColor: '#3f3f46',
cacheTrackTintColor: '#71717a',
bubbleBackgroundColor: '#0ea5e9',
}}
/>
);
}You can also import the controls separately:
import { VideoPlayer, PlayerControls } from 'react-native-cross-player';VideoPlayer is the ready-made player shell, but the package also exports the lower-level pieces used to build it. Use usePlayerController when you want your own layout, your own buttons, or a custom TV/mobile/web player surface while still reusing the package playback logic for HLS, subtitles, source switching, proxy handling, fullscreen, audio tracks, and progress state.
The custom-player flow is:
- Render your own
react-native-videoelement. - Pass
videoRef,controlsRef, andplayerViewRefintousePlayerController. - Spread
controller.nativeVideoPropsonto your video element. - Keep native video controls disabled and drive playback through
controller.controls. - Render your own UI, or reuse the exported
PlayerControlsoverlay.
import React from 'react';
import { Pressable, Text, View } from 'react-native';
import Video from 'react-native-video';
import { PlayerControls, usePlayerController } from 'react-native-cross-player';
const playerId = 'custom-player';
export function CustomPlayer() {
const videoRef = React.useRef(null);
const controlsRef = React.useRef(null);
const playerViewRef = React.useRef(null);
const controller = usePlayerController({
playerId,
videoRef,
controlsRef,
playerViewRef,
videoSources: [
{
id: 'main',
playerId,
label: 'Main stream',
source: 'https://tears-of-steel-subtitles.s3.amazonaws.com/tos.mp4',
format: 'mp4',
},
],
subtitleSources: [],
initialVideoSource: 0,
});
return (
<View ref={playerViewRef} style={{ flex: 1, backgroundColor: 'black' }}>
<Video
ref={videoRef}
{...controller.nativeVideoProps}
controls={false}
paused={controller.playerState.paused}
resizeMode="contain"
style={{ flex: 1 }}
/>
<Pressable onPress={() => controller.controls.setPause(!controller.playerState.paused)}>
<Text>{controller.playerState.paused ? 'Play' : 'Pause'}</Text>
</Pressable>
<PlayerControls
ref={controlsRef}
videoTitle="Custom player"
controls={controller.controls}
resources={controller.playbackResources}
playerState={controller.playerState}
/>
</View>
);
}The control bar is a large tree — buttons, dropdowns, scrubber, focus guides — and everything under PlayerControls is memoised, so it does nothing while you watch. But memoisation compares prop identity, not value. A single unstable prop from your page defeats it and re-renders the entire tree, re-resolving the styles of hundreds of views. On a TV box that is the difference between 60fps and a visibly janky control bar.
Bad — every prop here is a new object/array/element on each render:
<VideoPlayer
videoTitle="Big Buck Bunny"
playerConfig={{
playerId: 'player',
videoSources: [{ id: 'main', playerId: 'player', label: 'Main', source: url, format: 'mp4' }],
initialVideoSource: 0,
}}
theme={{ minimumTrackTintColor: '#38bdf8' }}
HeaderRightElement={<MuteButton muted={muted} />}
/>This looks harmless, and it is the most common cause of player jank: any state change anywhere in the page rebuilds all of those props, and the control bar re-renders with them.
Good — stable identities, so the controls re-render only on real changes:
const THEME = { minimumTrackTintColor: '#38bdf8' }; // hoisted — never changes
function Page() {
const videoSources = React.useMemo(
() => [{ id: 'main', playerId: 'player', label: 'Main', source: url, format: 'mp4' }],
[url],
);
const playerConfig = React.useMemo(
() => ({ playerId: 'player', videoSources, initialVideoSource: 0 }),
[videoSources],
);
const headerRight = React.useMemo(() => <MuteButton muted={muted} />, [muted]);
return <VideoPlayer playerConfig={playerConfig} theme={THEME} HeaderRightElement={headerRight} />;
}What the player stabilises for you, and what it cannot:
| Prop | Stabilised? |
|---|---|
playerConfig.videoSources / subtitleSources |
Yes — compared by content, so an inline array is tolerated (it still costs a comparison every render; prefer useMemo). |
theme |
No — an inline object re-renders the whole control bar. Hoist it or useMemo it. |
HeaderRightElement |
No — inline JSX is a new element every render. useMemo it. |
onClosePlayer, onNextVideo, onProgress, onEnd, onError, onControlVisibilityChange, … |
Yes — read through a ref, so they are safe to pass inline. |
Rule of thumb: callbacks are safe to inline. Objects, arrays and JSX elements are not.
Profiling: measure on a release build via @callstack/inspector. React DevTools attaches to dev builds, where every render is far slower than production — dev timings will point you at problems that do not exist in a shipped app. When you open a slow commit in the profiler, a component listed as props changed: theme or props changed: resources is an unstable prop coming from your page, not from the player.
The package entry exports more than the ready-made components:
- UI:
VideoPlayer,PlayerControls,VideoPlayerRef,PlayerControlsRef,ControlsProps. - Controller hooks:
usePlayerController,PlayerControllerProps,useWebKeyboard. - Media helpers:
createM3U8Source,createMasterM3U8Raw,createVTTSource,convertSRTtoVTT,createM3U8File,createVTTFile,clearBlobFiles,clearBlobGroup,revokeAllBlobURLs. - HLS/proxy helpers:
HlsProxy,HlsProxyManager,ProxyLoader,ProxyPlaylistLoader,ProxyFragmentLoader,HlsProxyConfig,ProxyURLResolverCallback. - Types:
VideoSource,SubtitleSource,SourceRequestOptions,M3U8BlobOptions,M3U8PlaylistTrack,M38USubtitleTrack,M3U8AudioTrack,SubtitleBlobOptions,SourceTypes,SubtitleTypes,TextEncoding,VideoFormats. - Utilities:
CustomPlayerError,isCustomPlayerError,detectSourceType,detectSubtitleType,detectSubtitleEncoding,CNPLogger,ProxyLogger,CSS_PATH.
For the complete, structured API reference (component props, hook options, controllers and types) see API.md.
Why the API is exposed
This library exports both higher-level UI components (VideoPlayer, PlayerControls) and lower-level hooks/controllers (for example usePlayerController) so you can either use the ready-made player UI or build your own custom player interface. Exposing the controller makes it straightforward to wire the playback logic into a custom layout or use different control components while reusing the underlying HLS/subtitle/proxy logic.
usePlayerController is exported from the package entry so consumers can import it directly:
import { usePlayerController } from 'react-native-cross-player';VideoPlayer is a small wrapper that wires usePlayerController and PlayerControls together. Important props:
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
videoTitle |
string |
— | Yes | Title displayed in the controls header |
language |
Languages |
en |
No | Localization setting |
playerConfig |
Omit<PlayerControllerProps, "playerViewRef" | "videoRef" | "controlsRef"> |
{} |
Yes | Full runtime configuration for usePlayerController. See key fields below. |
viewStyle |
StyleProp<ViewStyle> |
— | No | Style for the outer container view |
videoStyle |
StyleProp<ViewStyle> |
— | No | Style applied to the native video element |
theme |
SliderThemeType |
— | No | Optional slider theme forwarded to the built-in progress bar from react-native-awesome-slider |
onControlVisibilityChange |
(visible: boolean) => void |
— | No | Called whenever the built-in controls become visible or hidden |
onSourceChange |
(index: number, source: VideoSource) => void |
— | No | Called when the active video source changes, including initial source selection and imperative/UI-driven switches |
onSubtitleChange |
(index: number, subtitle: SubtitleSource) => void |
— | No | Called when a subtitle track becomes active. It is not fired when subtitles are turned off. |
onPlaybackChange |
(isPlaying: boolean) => void |
— | No | Called when playback toggles between playing and paused after the player has mounted. |
onProgress |
(currentTime: number) => void |
— | No | Called on each native progress update with the current playback time in seconds. |
onEnd |
() => void |
— | No | Called when playback reaches the end of the active media. |
onError |
(error: PlayerError) => void |
— | No | Called when a source fails while preparing, while switching, or during playback. The player still shows its own error state; this only reports it. Act on fatal only. |
theme lets you override the colors used by the built-in seek slider without replacing the controls UI.
onError reports a failure the moment it happens, so a host holding several alternative
links for the same media can switch immediately instead of waiting for a "no playback yet"
timeout. phase says how far the source got — initialize and source-change mean it
never played at all, e.g. a rejected manifest.
| Field | Type | Description |
|---|---|---|
phase |
"initialize" | "source-change" | "playback" |
Stage reached when the source failed. |
sourceIndex |
number |
Index into videoSources, or -1 before a source was selected. |
sourceId |
string | number |
Id of the failing source, when one was selected. |
fatal |
boolean |
true when the source is unusable. |
message |
string |
Human-readable summary, localized where available. |
cause |
unknown |
Underlying error or native/hls.js payload, for logging. |
Only switch away when fatal is true — non-fatal hls.js errors are recoverable and retried
internally, so treating them as failures abandons a source that is still fine.
<VideoPlayer
onError={(error) => {
if (error.fatal) switchToNextSource(error.sourceIndex);
}}
{...rest}
/>import { VideoPlayer } from 'react-native-cross-player';
import type { SliderThemeType } from 'react-native-awesome-slider';
const sliderTheme: SliderThemeType = {
minimumTrackTintColor: '#0ea5e9',
maximumTrackTintColor: '#3f3f46',
cacheTrackTintColor: '#71717a',
bubbleBackgroundColor: '#0ea5e9',
};
<VideoPlayer videoTitle="Demo" playerConfig={playerConfig} theme={sliderTheme} />;You can observe source and subtitle switches from the built-in UI or imperative ref calls:
<VideoPlayer
videoTitle="Demo"
playerConfig={playerConfig}
onSourceChange={(index, source) => {
console.log('Active source', index, source.label);
}}
onSubtitleChange={(index, subtitle) => {
console.log('Active subtitle', index, subtitle.label ?? subtitle.langISO);
}}
onPlaybackChange={(isPlaying) => {
console.log('Playback changed', isPlaying ? 'playing' : 'paused');
}}
onProgress={(currentTime) => {
console.log('Current time', currentTime);
}}
onEnd={() => {
console.log('Playback finished');
}}
/>VideoPlayer also exposes an imperative ref API for playback and track/source switching.
type VideoPlayerRef = {
setState: (state: State) => void;
setSubtitle: (index: number) => Promise<void>;
setVideoSource: (index: number) => Promise<void>;
seek: (time: number) => void;
play: () => void;
pause: () => void;
getCurrentTime: () => Promise<number>;
getCurrentVideoIndex: () => number;
getCurrentSubtitleIndex: () => number;
};setSubtitle(index) selects a subtitle track by index, setVideoSource(index) switches the active video source by index, and the getter methods return the currently selected source and subtitle indexes.
Example usage (concise playerConfig):
const playerConfig = {
playerId: 'demo-player',
videoSources: [{
id: 'main',
playerId: 'demo-player',
label: 'Main stream',
source: 'https://example.com/stream.m3u8',
format: 'm3u8'
}],
subtitleSources: [],
initialVideoSource: -1,
autoStart: false,
// Player-level proxy settings bundled into one object:
proxyConfig: {
url: 'https://proxy.example.com', // proxy base URL
resolver: (targetURL, proxyURL, originHeaders) =>
`${proxyURL}/${encodeURIComponent(btoa(targetURL))}`, // builds the proxied URL
headers: { 'X-Proxy-Token': 'my-token' }, // (optional) proxy auth as real headers
query: { token: 'my-token' } // (optional) proxy auth as a query param
}
};
<VideoPlayer playerConfig={playerConfig} />Key playerConfig fields (examples):
| Field | Type | Default | Description |
|---|---|---|---|
videoSources |
VideoSource[] |
[] |
List of sources to present in the sources menu |
subtitleSources |
SubtitleSource[] |
[] |
List of subtitle tracks to offer |
initialVideoSource |
number |
-1 |
Index to auto-select a video source on mount |
initialSubtitleSource |
number |
-1 |
Index to auto-select a subtitle |
initialAudioTrack |
number |
-1 |
Index to auto-select an audio track (applied after load) |
autoStart |
boolean |
false |
Start playback automatically after load |
startPosition |
number |
0 |
Seek position (seconds) applied on initial load |
proxyConfig |
{ url?, resolver?, headers?, query? } |
— | Player-level proxy settings applied to every proxied source: url (proxy base URL), resolver (builds the proxied URL), and optional proxy auth headers/query. A source's own options override per source. |
lazyLoadSources |
boolean |
true |
Defer creation of blob/playlist URLs until first use |
preservePlaybackOnSourceChange |
boolean |
true |
Preserve current time when switching sources |
maxResolutionHeight |
number |
— | Prefer/filter quality levels with height <= this value. Useful to cap resolution for bandwidth or device constraints. |
For the full API (controller methods, control props, types and helper exports) see API.md.
Contributions are welcome. Open an issue or submit a PR. Follow the code style in the repo and add tests if you introduce behavior changes.
This project is licensed under the MIT License. See the full license text in LICENSE.