Skip to content

Repository files navigation

Grabit Engine

React Native Cross Player

npm license


React Native / Web video player wrapper with HLS, subtitles and proxy support.

Installation

Install the package:

npm install react-native-cross-player
# or
yarn add react-native-cross-player

Install 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 tailwindcss

Notes:

  • 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.

NativeWind Setup (Required)

This library uses NativeWind v4 for styling. You must set up NativeWind in your consuming application.

1. Import Component Styles

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' });

Usage

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';

Build your own video 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-video element.
  • Pass videoRef, controlsRef, and playerViewRef into usePlayerController.
  • Spread controller.nativeVideoProps onto your video element.
  • Keep native video controls disabled and drive playback through controller.controls.
  • Render your own UI, or reuse the exported PlayerControls overlay.
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>
	);
}

Performance

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.

Public exports

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.

API & exports

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 props (detailed)

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.

Reacting to source failures

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 ref methods

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.

Contributing

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.

License

This project is licensed under the MIT License. See the full license text in LICENSE.


About

React Native Video Player Wrapper with support for HLS, Subtitles and Proxy across Web, Android, iOS

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages