This guide walks you through creating custom plugins for React Native Video. You’ll learn how to implement plugins on both Android and iOS, handle lifecycle events, modify sources, and integrate with the plugin system.
Prerequisites
Before creating a plugin, ensure you’re familiar with:
React Native development and native modules
Android development (Kotlin, ExoPlayer)
iOS development (Swift, AVFoundation)
React Native Video’s plugin architecture
Plugins are native code extensions. You’ll need to write Kotlin for Android and Swift for iOS.
Plugin Development Options
Option 1: Extend ReactNativeVideoPlugin (Recommended)
The easiest way to create a plugin is to extend the base class:
Advantages:
Auto-registration on instantiation
Default implementations for all methods
Only override what you need
Auto-cleanup on iOS (via deinit)
Use when: Building a standard plugin with typical lifecycle needs
Option 2: Implement ReactNativeVideoPluginSpec
Implement the interface directly for full control:
Advantages:
Complete control over behavior
No base class overhead
Custom registration logic
Use when: You need fine-grained control or have specific architecture requirements
Creating Your First Plugin
Let’s build an Analytics Plugin that tracks playback events and modifies video sources.
Android Implementation
Create the plugin class
Create a new Kotlin file in your Android module: android/src/main/java/com/yourapp/AnalyticsPlugin.kt
package com.yourapp
import com.twg.video.core.plugins.ReactNativeVideoPlugin
import com.twg.video.core.plugins.NativeVideoPlayerSource
import com.twg.video.core.player.NativeVideoPlayer
import java.lang.ref.WeakReference
import android.util.Log
class AnalyticsPlugin : ReactNativeVideoPlugin ( "Analytics" ) {
private val TAG = "AnalyticsPlugin"
private val sessionId = generateSessionId ()
override fun onPlayerCreated (player: WeakReference < NativeVideoPlayer >) {
player. get ()?. let { p ->
Log. d (TAG, "Player created for URI: ${ p.source.uri } " )
trackEvent ( "player_created" , mapOf (
"uri" to p.source.uri,
"session_id" to sessionId
))
}
}
override fun onPlayerDestroyed (player: WeakReference < NativeVideoPlayer >) {
Log. d (TAG, "Player destroyed" )
trackEvent ( "player_destroyed" , mapOf (
"session_id" to sessionId
))
}
override fun overrideSource (source: NativeVideoPlayerSource ): NativeVideoPlayerSource {
// Add analytics headers to all requests
source.headers[ "X-Session-Id" ] = sessionId
source.headers[ "X-App-Version" ] = getAppVersion ()
source.headers[ "X-User-Id" ] = getUserId ()
Log. d (TAG, "Modified source with analytics headers" )
return source
}
private fun generateSessionId (): String {
return java.util.UUID. randomUUID (). toString ()
}
private fun getAppVersion (): String {
// Implement version retrieval
return "1.0.0"
}
private fun getUserId (): String {
// Implement user ID retrieval
return "user-123"
}
private fun trackEvent (eventName: String , properties: Map < String , String >) {
// Send to your analytics service
Log. d (TAG, "Event: $eventName , Properties: $properties " )
}
}
Register the plugin
Instantiate your plugin in your Application or Activity: class MainApplication : Application () {
private lateinit var analyticsPlugin: AnalyticsPlugin
override fun onCreate () {
super . onCreate ()
// Plugin auto-registers on instantiation
analyticsPlugin = AnalyticsPlugin ()
}
override fun onTerminate () {
// Clean up (important!)
PluginsRegistry.shared. unregister (analyticsPlugin)
super . onTerminate ()
}
}
iOS Implementation
Create the plugin class
Create a new Swift file in your iOS module: ios/AnalyticsPlugin.swift
import Foundation
import ReactNativeVideo
class AnalyticsPlugin : ReactNativeVideoPlugin {
private let sessionId: String
init () {
self . sessionId = UUID (). uuidString
super . init ( name : "Analytics" )
}
override func onPlayerCreated ( player : Weak<NativeVideoPlayer>) {
guard let p = player. value else { return }
print ( "Player created for URI: \( p. source . uri ) " )
trackEvent ( "player_created" , properties : [
"uri" : p. source . uri ,
"session_id" : sessionId
])
}
override func onPlayerDestroyed ( player : Weak<NativeVideoPlayer>) {
print ( "Player destroyed" )
trackEvent ( "player_destroyed" , properties : [
"session_id" : sessionId
])
}
override func overrideSource ( source : NativeVideoPlayerSource) async -> NativeVideoPlayerSource {
// Add analytics headers
var modifiedSource = source
modifiedSource. headers [ "X-Session-Id" ] = sessionId
modifiedSource. headers [ "X-App-Version" ] = getAppVersion ()
modifiedSource. headers [ "X-User-Id" ] = await getUserId ()
print ( "Modified source with analytics headers" )
return modifiedSource
}
deinit {
// Plugin auto-unregisters via base class deinit
print ( "AnalyticsPlugin deallocated" )
}
// MARK: - Private helpers
private func getAppVersion () -> String {
return Bundle. main . infoDictionary ? [ "CFBundleShortVersionString" ] as? String ?? "unknown"
}
private func getUserId () async -> String {
// Implement user ID retrieval
return "user-123"
}
private func trackEvent ( _ eventName : String , properties : [ String : String ]) {
// Send to your analytics service
print ( "Event: \( eventName ) , Properties: \( properties ) " )
}
}
Register the plugin
Instantiate your plugin in your AppDelegate: import UIKit
import ReactNativeVideo
@UIApplicationMain
class AppDelegate : UIResponder , UIApplicationDelegate {
var analyticsPlugin: AnalyticsPlugin ?
func application ( _ application : UIApplication,
didFinishLaunchingWithOptions launchOptions : [UIApplication.LaunchOptionsKey: Any ] ? ) -> Bool {
// Plugin auto-registers on instantiation
analyticsPlugin = AnalyticsPlugin ()
return true
}
// Plugin auto-unregisters when deallocated
}
Advanced Plugin Examples
CDN Switching Plugin
Automatically route videos through optimal CDN endpoints:
class CDNPlugin : ReactNativeVideoPlugin ( "CDN" ) {
private val cdnMapping = mapOf (
"storage.googleapis.com" to "cdn.fastly.net" ,
"s3.amazonaws.com" to "cloudfront.net"
)
override fun overrideSource (source: NativeVideoPlayerSource ): NativeVideoPlayerSource {
val originalUri = android.net.Uri. parse (source.uri)
val host = originalUri.host
// Check if we have a CDN mapping
cdnMapping[host]?. let { cdnHost ->
val newUri = originalUri. buildUpon ()
. authority (cdnHost)
. build ()
source.uri = newUri. toString ()
Log. d ( "CDNPlugin" , "Switched to CDN: $host -> $cdnHost " )
}
return source
}
}
class CDNPlugin : ReactNativeVideoPlugin {
private let cdnMapping: [ String : String ] = [
"storage.googleapis.com" : "cdn.fastly.net" ,
"s3.amazonaws.com" : "cloudfront.net"
]
init () {
super . init ( name : "CDN" )
}
override func overrideSource ( source : NativeVideoPlayerSource) async -> NativeVideoPlayerSource {
guard let url = URL ( string : source. uri ),
let host = url.host,
let cdnHost = cdnMapping[host] else {
return source
}
var components = URLComponents ( url : url, resolvingAgainstBaseURL : false )
components ? . host = cdnHost
if let newUrl = components ? . url {
var modifiedSource = source
modifiedSource. uri = newUrl. absoluteString
print ( "CDNPlugin: Switched to CDN: \( host ) -> \( cdnHost ) " )
return modifiedSource
}
return source
}
}
Authentication Plugin
Add dynamic bearer tokens to video requests:
class AuthPlugin : ReactNativeVideoPlugin ( "Auth" ) {
private var accessToken: String ? = null
fun setAccessToken (token: String ) {
accessToken = token
}
override fun overrideSource (source: NativeVideoPlayerSource ): NativeVideoPlayerSource {
accessToken?. let { token ->
source.headers[ "Authorization" ] = "Bearer $token "
Log. d ( "AuthPlugin" , "Added auth header" )
} ?: run {
Log. w ( "AuthPlugin" , "No access token available" )
}
return source
}
}
// Usage:
// authPlugin.setAccessToken("eyJhbGc...")
class AuthPlugin : ReactNativeVideoPlugin {
private var accessToken: String ?
init () {
super . init ( name : "Auth" )
}
func setAccessToken ( _ token : String ) {
accessToken = token
}
override func overrideSource ( source : NativeVideoPlayerSource) async -> NativeVideoPlayerSource {
guard let token = accessToken else {
print ( "AuthPlugin: No access token available" )
return source
}
var modifiedSource = source
modifiedSource. headers [ "Authorization" ] = "Bearer \( token ) "
print ( "AuthPlugin: Added auth header" )
return modifiedSource
}
}
// Usage:
// authPlugin.setAccessToken("eyJhbGc...")
Quality Control Plugin (Android)
Force specific bitrate or resolution:
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.common.TrackSelectionParameters
class QualityPlugin : ReactNativeVideoPlugin ( "Quality" ) {
private val maxBitrate = 2_000_000 // 2 Mbps
private val maxVideoWidth = 1920
private val maxVideoHeight = 1080
override fun onPlayerCreated (player: WeakReference < NativeVideoPlayer >) {
player. get ()?.exoPlayer?. let { exoPlayer ->
val trackSelector = exoPlayer.trackSelector as ? DefaultTrackSelector
trackSelector?.parameters = trackSelector?. buildUponParameters ()
?. setMaxVideoBitrate (maxBitrate)
?. setMaxVideoSize (maxVideoWidth, maxVideoHeight)
?. build () ?: return
Log. d ( "QualityPlugin" , "Applied quality constraints" )
}
}
}
Custom Cache Plugin (Android)
Implement custom caching logic:
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.SimpleCache
class CachePlugin ( private val cache: SimpleCache ) : ReactNativeVideoPlugin ( "Cache" ) {
override fun getMediaDataSourceFactory (
source: NativeVideoPlayerSource ,
mediaDataSourceFactory: DataSource .Factory
): DataSource . Factory {
// Only cache VOD content, not live streams
if (source.uri. contains ( ".m3u8" ) && source.uri. contains ( "live" )) {
return mediaDataSourceFactory
}
// Wrap with cache data source
return CacheDataSource. Factory ()
. setCache (cache)
. setUpstreamDataSourceFactory (mediaDataSourceFactory)
. setCacheWriteDataSinkFactory ( null ) // Read-only mode
}
override fun shouldDisableCache (source: NativeVideoPlayerSource ): Boolean {
// Disable default cache for live content
return source.uri. contains ( "live" )
}
}
Exposing Plugins to JavaScript
To control plugins from JavaScript, create a native module bridge:
Using Nitro Modules (Recommended)
Define the interface
src/AnalyticsPluginManager.nitro.ts
import type { HybridObject } from 'react-native-nitro-modules' ;
export interface AnalyticsPluginManager
extends HybridObject <{ ios : 'swift' ; android : 'kotlin' }> {
enable () : void ;
disable () : void ;
setUserId ( userId : string ) : void ;
trackEvent ( eventName : string , properties : Record < string , string >) : void ;
readonly isEnabled : boolean ;
}
Implement on Android
android/src/main/java/com/yourapp/AnalyticsPluginManager.kt
class AnalyticsPluginManager : HybridAnalyticsPluginManagerSpec () {
private var plugin: AnalyticsPlugin ? = null
override fun enable () {
if (plugin == null ) {
plugin = AnalyticsPlugin ()
}
}
override fun disable () {
plugin?. let {
PluginsRegistry.shared. unregister (it)
plugin = null
}
}
override fun setUserId (userId: String ) {
plugin?. setUserId (userId)
}
override fun trackEvent (eventName: String , properties: ReadableMap ) {
plugin?. trackCustomEvent (eventName, properties. toHashMap ())
}
override val isEnabled: Boolean
get () = plugin != null
}
Implement on iOS
ios/AnalyticsPluginManager.swift
class AnalyticsPluginManager : HybridAnalyticsPluginManagerSpec {
private var plugin: AnalyticsPlugin ?
func enable () {
if plugin == nil {
plugin = AnalyticsPlugin ()
}
}
func disable () {
if let plugin = plugin {
PluginsRegistry. shared . unregister ( plugin : plugin)
self . plugin = nil
}
}
func setUserId ( _ userId : String ) {
plugin ? . setUserId (userId)
}
func trackEvent ( _ eventName : String , properties : [ String : String ]) {
plugin ? . trackCustomEvent (eventName, properties : properties)
}
var isEnabled: Bool {
return plugin != nil
}
}
Use from JavaScript
import { NitroModules } from 'react-native-nitro-modules' ;
import type { AnalyticsPluginManager } from './AnalyticsPluginManager.nitro' ;
const manager = NitroModules . createHybridObject < AnalyticsPluginManager >( 'AnalyticsPluginManager' );
export function enableAnalytics () {
manager . enable ();
}
export function disableAnalytics () {
manager . disable ();
}
export function setUserId ( userId : string ) {
manager . setUserId ( userId );
}
export function trackEvent ( name : string , properties : Record < string , string >) {
manager . trackEvent ( name , properties );
}
export const isAnalyticsEnabled = manager . isEnabled ;
Plugin Interface Reference
Implement these methods in your custom plugin:
Lifecycle Methods
// Android
override fun onPlayerCreated (player: WeakReference < NativeVideoPlayer >)
override fun onPlayerDestroyed (player: WeakReference < NativeVideoPlayer >)
override fun onVideoViewCreated (view: WeakReference < VideoView >)
override fun onVideoViewDestroyed (view: WeakReference < VideoView >)
// iOS
override func onPlayerCreated ( player : Weak<NativeVideoPlayer>)
override func onPlayerDestroyed ( player : Weak<NativeVideoPlayer>)
override func onVideoViewCreated ( view : Weak<VideoComponentView>)
override func onVideoViewDestroyed ( view : Weak<VideoComponentView>)
Source Modification
// Android
override fun overrideSource (source: NativeVideoPlayerSource ): NativeVideoPlayerSource
// iOS (async!)
override func overrideSource ( source : NativeVideoPlayerSource) async -> NativeVideoPlayerSource
DRM Support
// Android
override fun getDRMManager (source: NativeVideoPlayerSource ): DRMManagerSpec ?
// iOS
override func getDRMManager ( source : NativeVideoPlayerSource) async -> ( any DRMManagerSpec) ?
Android-Only Methods
override fun getMediaDataSourceFactory (
source: NativeVideoPlayerSource ,
mediaDataSourceFactory: DataSource .Factory
): DataSource .Factory?
override fun getMediaSourceFactory (
source: NativeVideoPlayerSource ,
mediaSourceFactory: MediaSource .Factory,
mediaDataSourceFactory: DataSource .Factory
): MediaSource .Factory?
override fun getMediaItemBuilder (
source: NativeVideoPlayerSource ,
mediaItemBuilder: MediaItem .Builder
): MediaItem .Builder?
override fun shouldDisableCache (source: NativeVideoPlayerSource ): Boolean
Best Practices
Always check weak references
Player and view references are weak to prevent memory leaks. Always verify they’re not null: // Android - use let
player. get ()?. let { p ->
// Safe to use 'p'
}
// Android - or check explicitly
val p = player. get ()
if (p != null ) {
// Safe to use 'p'
}
// iOS - use guard
guard let p = player. value else { return }
// Safe to use 'p'
// iOS - or if let
if let p = player. value {
// Safe to use 'p'
}
Return null for optional methods
If your plugin doesn’t handle a particular feature, return null: override fun getDRMManager (source: NativeVideoPlayerSource ): DRMManagerSpec ? {
// Only handle custom DRM
if (source.drmType == "custom" ) {
return CustomDRMManager (source)
}
return null // Let other plugins handle it
}
Plugin methods are called in the playback path. Avoid heavy operations: // BAD - blocks playback
override fun overrideSource (source: NativeVideoPlayerSource ): NativeVideoPlayerSource {
val token = fetchTokenFromServer () // Slow network call!
source.headers[ "Authorization" ] = token
return source
}
// GOOD - use cached values
override fun overrideSource (source: NativeVideoPlayerSource ): NativeVideoPlayerSource {
val token = cachedToken ?: "" // Fast lookup
source.headers[ "Authorization" ] = token
return source
}
Always unregister plugins and release resources: // Android - manual cleanup
override fun onDestroy () {
PluginsRegistry.shared. unregister (plugin)
super . onDestroy ()
}
// iOS - automatic via deinit
deinit {
// Base class handles unregistration
// Clean up your custom resources here
cancellables. forEach { $0 . cancel () }
}
Don’t crash the video player on errors: override fun overrideSource (source: NativeVideoPlayerSource ): NativeVideoPlayerSource {
try {
// Risky operation
val modifiedUrl = transformUrl (source.uri)
source.uri = modifiedUrl
} catch (e: Exception ) {
Log. e ( "MyPlugin" , "Failed to transform URL" , e)
// Return original source on error
}
return source
}
Use async on iOS when needed
iOS plugin methods support async/await. Use it for I/O operations: override func overrideSource ( source : NativeVideoPlayerSource) async -> NativeVideoPlayerSource {
var modified = source
// Async token fetch is OK on iOS
if let token = await fetchToken () {
modified. headers [ "Authorization" ] = "Bearer \( token ) "
}
return modified
}
Testing Your Plugin
Unit Testing
Android (JUnit)
iOS (XCTest)
import org.junit.Test
import org.junit.Assert. *
import java.lang.ref.WeakReference
class AnalyticsPluginTest {
@Test
fun `overrideSource adds session header` () {
val plugin = AnalyticsPlugin ()
val source = NativeVideoPlayerSource (). apply {
uri = "https://example.com/video.mp4"
}
val modified = plugin. overrideSource (source)
assertNotNull (modified.headers[ "X-Session-Id" ])
assertTrue (modified.headers[ "X-Session-Id" ] !! . isNotEmpty ())
}
@Test
fun `onPlayerCreated handles null player` () {
val plugin = AnalyticsPlugin ()
val weakRef = WeakReference < NativeVideoPlayer >( null )
// Should not crash
plugin. onPlayerCreated (weakRef)
}
}
import XCTest
@testable import YourModule
class AnalyticsPluginTests : XCTestCase {
func testOverrideSourceAddsSessionHeader () async {
let plugin = AnalyticsPlugin ()
var source = NativeVideoPlayerSource ()
source. uri = "https://example.com/video.mp4"
let modified = await plugin. overrideSource ( source : source)
XCTAssertNotNil (modified. headers [ "X-Session-Id" ])
XCTAssertFalse (modified. headers [ "X-Session-Id" ] ! . isEmpty )
}
func testOnPlayerCreatedHandlesNilPlayer () {
let plugin = AnalyticsPlugin ()
let weakPlayer = Weak < NativeVideoPlayer > ( nil )
// Should not crash
plugin. onPlayerCreated ( player : weakPlayer)
}
}
Integration Testing
Test your plugin with real video playback:
import { enableAnalytics , setUserId } from './analytics-plugin' ;
import { useVideoPlayer , VideoView } from 'react-native-video' ;
// Enable plugin before tests
enableAnalytics ();
setUserId ( 'test-user-123' );
function TestPlayer () {
const player = useVideoPlayer ({
uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4' ,
});
return < VideoView player ={ player } style ={{ width : 300 , height : 200 }} />;
}
// Verify headers are added via network inspection or logging
Publishing Your Plugin
If you want to share your plugin with the community:
Create a package
Structure your plugin as a standalone npm package: @your-org/react-native-video-analytics/
├── android/
│ └── src/main/java/...
├── ios/
│ └── AnalyticsPlugin.swift
├── src/
│ └── index.ts
├── package.json
└── README.md
Configure package.json
{
"name" : "@your-org/react-native-video-analytics" ,
"version" : "1.0.0" ,
"description" : "Analytics plugin for React Native Video" ,
"main" : "lib/index.js" ,
"types" : "lib/index.d.ts" ,
"peerDependencies" : {
"react-native" : "*" ,
"react-native-video" : ">=7.0.0"
}
}
Document usage
Create comprehensive README with:
Installation instructions
Setup guide for Android and iOS
API reference
Usage examples
Troubleshooting section
Publish to npm
npm publish --access public
Next Steps
Plugin Overview Learn about plugin architecture
DRM Plugin Study the official DRM plugin
API Reference Full plugin interface docs
Resources