diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml
index 126cb54cac..0c24bc171d 100644
--- a/.github/workflows/build-ios.yml
+++ b/.github/workflows/build-ios.yml
@@ -78,7 +78,7 @@ jobs:
-scheme BareExample \
-sdk iphonesimulator \
-configuration Debug \
- -destination 'platform=iOS Simulator,name=iPhone 14' \
+ -destination 'platform=iOS Simulator,name=iPhone 16' \
build \
CODE_SIGNING_ALLOWED=NO | xcpretty"
@@ -142,7 +142,7 @@ jobs:
-scheme BareExample \
-sdk iphonesimulator \
-configuration Debug \
- -destination 'platform=iOS Simulator,name=iPhone 14' \
+ -destination 'platform=iOS Simulator,name=iPhone 16' \
build \
CODE_SIGNING_ALLOWED=NO | xcpretty"
@@ -209,6 +209,6 @@ jobs:
-scheme BareExample \
-sdk iphonesimulator \
-configuration Debug \
- -destination 'platform=iOS Simulator,name=iPhone 14' \
+ -destination 'platform=iOS Simulator,name=iPhone 16' \
build \
CODE_SIGNING_ALLOWED=NO | xcpretty"
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c54a1a9c75..c0b804d30d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,41 @@
+## [6.19.1](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.19.0...v6.19.1) (2026-03-15)
+
+
+### Bug Fixes
+
+* **ios:** IMA ad container resizing on orientation change ([#4771](https://github.com/TheWidlarzGroup/react-native-video/issues/4771)) ([fc936c4](https://github.com/TheWidlarzGroup/react-native-video/commit/fc936c49ef3c2734173442042b7d5038aeaef301))
+* RCTVideoManager crash in bridgeless mode RN0.84 ([#4855](https://github.com/TheWidlarzGroup/react-native-video/issues/4855)) ([92b0a0e](https://github.com/TheWidlarzGroup/react-native-video/commit/92b0a0e416c7f313120a811cd2dc972f87b1c82f))
+
+# [6.19.0](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.18.0...v6.19.0) (2026-01-19)
+
+
+### Bug Fixes
+
+* **android:** correct videoTrack type definitions to match Android implementation ([#4778](https://github.com/TheWidlarzGroup/react-native-video/issues/4778)) ([commit](https://github.com/TheWidlarzGroup/react-native-video/commit/f38717778515b06c462fe75dcd94d2cce5ba3f95))
+
+
+### Features
+
+* **BREAKING CHANGE:** add DAI support ([#4816](https://github.com/TheWidlarzGroup/react-native-video/issues/4816)) ([commit](https://github.com/TheWidlarzGroup/react-native-video/commit/88ac1ae1dcdc907415f806bd64bd3d0a92ccd7d1))
+
+# [6.18.0](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.17.0...v6.18.0) (2025-11-18)
+
+
+### Bug Fixes
+
+* **android:** prevent duplicate `onVideoEnd` callback on prop changes ([#4762](https://github.com/TheWidlarzGroup/react-native-video/issues/4762)) ([05cd597](https://github.com/TheWidlarzGroup/react-native-video/commit/05cd5972c21ebcacf3cd5952e92f121a84b5c9a9))
+* **ci:** update ios device for builds ([#4757](https://github.com/TheWidlarzGroup/react-native-video/issues/4757)) ([a9f7524](https://github.com/TheWidlarzGroup/react-native-video/commit/a9f752435f3d94abfdb37ef5dc9c444038e3ad6a))
+* entering PiP mode when controls are true ([#4776](https://github.com/TheWidlarzGroup/react-native-video/issues/4776)) ([ba65ab1](https://github.com/TheWidlarzGroup/react-native-video/commit/ba65ab123321713e537fc7ccb1ac3ed5676f1677))
+* **iOS:** use top-most presented view controller for fullscreen presentation on iOS ([#4753](https://github.com/TheWidlarzGroup/react-native-video/issues/4753)) ([5d75b48](https://github.com/TheWidlarzGroup/react-native-video/commit/5d75b482952a9cd3e5f59237e302137857739d4e))
+* prevent `audiovisualBackgroundPlaybackPolicy` crash ([#4763](https://github.com/TheWidlarzGroup/react-native-video/issues/4763)) ([fbb260e](https://github.com/TheWidlarzGroup/react-native-video/commit/fbb260e9164194a55d2b26404aea000e924e2f04))
+
+
+### Features
+
+* **ios:** add PublicAudioSessionManager for audio session management ([#4747](https://github.com/TheWidlarzGroup/react-native-video/issues/4747)) ([f2afd16](https://github.com/TheWidlarzGroup/react-native-video/commit/f2afd16d0bc7fc72e0b4d8400d74342244158674))
+
# [6.17.0](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.16.1...v6.17.0) (2025-10-06)
diff --git a/README.md b/README.md
index 36f6108125..3ab412db34 100644
--- a/README.md
+++ b/README.md
@@ -64,8 +64,8 @@ export default () => (
## 🧩 Plugins
-
-
+
+
### 1 · 📥 Offline SDK
@@ -74,7 +74,7 @@ export default () => (
If you're building a video-first app and need to **download HLS streams for offline playback**, you're in the right place.
-#### 👉 [Check Offline Video SDK for React Native](https://www.thewidlarzgroup.com/offline-video-sdk?utm_source=rnv&utm_medium=readme&utm_id=check-offline-video-sdk)
+#### 👉 [Check Offline Video SDK for React Native](https://sdk.thewidlarzgroup.com/offline-video?utm_source=rnv&utm_medium=readme&utm_id=check-offline-video-sdk)
This SDK supports:
- 🎞 Offline HLS playback
@@ -93,12 +93,45 @@ This SDK supports:
👉 **[Start Free Trial on the SDK Platform →](https://sdk.thewidlarzgroup.com/signup?utm_source=rnv&utm_medium=readme&utm_id=start-trial-offline-video-sdk)**
+---
+
+
+
+
+### 2 · ⚡ Background Upload SDK
+
+#### Need Reliable Video Uploads in React Native?
+
+If you're building a video-first app and need to **upload large video files reliably in the background**, you're in the right place.
+
+#### 👉 [Check Background Upload SDK for React Native](https://sdk.thewidlarzgroup.com/background-uploader?utm_source=rnv&utm_medium=readme&utm_id=check-background-upload-sdk)
+
+This SDK supports:
+- 📤 Background video uploads
+- 🔄 Automatic retry mechanisms
+- 📊 Upload progress tracking
+- 🛡️ Resume interrupted uploads
+- 📱 Works when app is backgrounded
+- 🔐 Secure upload handling
+
+---
+
+#### 🚀 Perfect for Apps Uploading Large Media
+
+Whether you're building social media apps, content platforms, or enterprise solutions, our Background Upload SDK ensures your users can upload videos seamlessly without interruption.
+
+#### 📞 Ready to Get Started?
+
+Contact us to learn more about integrating background video uploads into your React Native application.
+
+👉 **Contact us at [hi@thewidlarzgroup.com](mailto:hi@thewidlarzgroup.com)**
+
---
-### 2 · 🧪 Architecture
+### 3 · 🧪 Architecture
Write your own plugins to extend library logic, attach analytics or add custom workflows - **without forking** the core SDK.
-→ [Plugin documentation](https://docs.thewidlarzgroup.com/react-native-video/other/plugin?utm_source=rnv&utm_medium=readme&utm_id=plugin-text)
+→ [Plugin documentation](https://docs.thewidlarzgroup.com/react-native-video/docs/v6/other/plugin?utm_source=rnv&utm_medium=readme&utm_id=plugin-text)
---
@@ -108,7 +141,8 @@ Write your own plugins to extend library logic, attach analytics or add custom w
|----------|-------------|
| [**Professional Support Packages**](https://www.thewidlarzgroup.com/issue-boost?utm_source=rnv&utm_medium=readme&utm_campaign=professional-support-packages#Contact) | Priority bug-fixes, guaranteed SLAs, [roadmap influence](https://github.com/orgs/TheWidlarzGroup/projects/6) |
| [**Issue Booster**](https://www.thewidlarzgroup.com/issue-boost?utm_source=rnv&utm_medium=readme) | Fast-track urgent fixes with a pay‑per‑issue model |
-| [**Offline Video SDK**](https://www.thewidlarzgroup.com/offline-video-sdk/?utm_source=rnv&utm_medium=readme&utm_campaign=downloading&utm_id=offline-video-sdk-link) | Plug‑and‑play secure download solution for iOS & Android |
+| [**Offline Video SDK**](https://sdk.thewidlarzgroup.com/offline-video?utm_source=rnv&utm_medium=readme&utm_campaign=downloading&utm_id=offline-video-sdk-link) | Plug‑and‑play secure download solution for iOS & Android |
+| [**Background Upload SDK**](https://sdk.thewidlarzgroup.com/background-uploader?utm_source=rnv&utm_medium=readme&utm_campaign=uploading&utm_id=background-upload-sdk-link) | Reliable background upload solution for iOS & Android |
| [**Integration Support**](https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=readme&utm_campaign=integration-support#Contact) | Hands‑on help integrating video, DRM & offline into your app |
| [**Free DRM Token Generator**](https://www.thewidlarzgroup.com/services/free-drm-token-generator-for-video?utm_source=rnv&utm_medium=readme&utm_id=free-drm) | Generate Widevine / FairPlay tokens for testing |
| [**Ready Boilerplates**](https://www.thewidlarzgroup.com/showcases?utm_source=rnv&utm_medium=readme) | Ready-to-use apps with offline HLS/DASH DRM, video frame scrubbing, TikTok-style video feed, background uploads, Skia-based frame processor (R&D phase), and more |
diff --git a/android/gradle.properties b/android/gradle.properties
index acec563dba..d29fa1a5b1 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -4,7 +4,7 @@ RNVideo_targetSdkVersion=35
RNVideo_compileSdkVersion=35
RNVideo_ndkversion=27.1.12297006
RNVideo_buildToolsVersion=35.0.0
-RNVideo_media3Version=1.4.1
+RNVideo_media3Version=1.8.0
RNVideo_useExoplayerIMA=false
RNVideo_useExoplayerRtsp=false
RNVideo_useExoplayerSmoothStreaming=true
diff --git a/android/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java b/android/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java
new file mode 100644
index 0000000000..3ba01453c6
--- /dev/null
+++ b/android/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java
@@ -0,0 +1,65 @@
+package androidx.media3.exoplayer.ima;
+
+import android.content.Context;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.Player;
+import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
+import androidx.media3.exoplayer.source.MediaSource;
+import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
+
+public class ImaServerSideAdInsertionMediaSource {
+
+ public static class AdsLoader {
+ public void setPlayer(@Nullable Player player) {
+ }
+
+ public void release() {
+ }
+
+ public static class Builder {
+ public Builder(Context context, View playerView) {
+ }
+
+ public Builder setAdEventListener(Object listener) {
+ return this;
+ }
+
+ public Builder setAdErrorListener(Object listener) {
+ return this;
+ }
+
+ public AdsLoader build() {
+ return new AdsLoader();
+ }
+ }
+ }
+
+ public static class Factory implements MediaSource.Factory {
+ public Factory(AdsLoader adsLoader, MediaSource.Factory mediaSourceFactory) {
+ }
+
+ @Override
+ public MediaSource.Factory setDrmSessionManagerProvider(DrmSessionManagerProvider drmSessionManagerProvider) {
+ return this;
+ }
+
+ @Override
+ public MediaSource.Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
+ return this;
+ }
+
+ @Override
+ public int[] getSupportedTypes() {
+ return new int[0];
+ }
+
+ @Override
+ public MediaSource createMediaSource(MediaItem mediaItem) {
+ return null;
+ }
+ }
+}
+
diff --git a/android/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionUriBuilder.java b/android/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionUriBuilder.java
new file mode 100644
index 0000000000..7647d9e794
--- /dev/null
+++ b/android/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionUriBuilder.java
@@ -0,0 +1,26 @@
+package androidx.media3.exoplayer.ima;
+
+import android.net.Uri;
+
+public class ImaServerSideAdInsertionUriBuilder {
+ public ImaServerSideAdInsertionUriBuilder setAssetKey(String assetKey) {
+ return this;
+ }
+
+ public ImaServerSideAdInsertionUriBuilder setContentSourceId(String contentSourceId) {
+ return this;
+ }
+
+ public ImaServerSideAdInsertionUriBuilder setVideoId(String videoId) {
+ return this;
+ }
+
+ public ImaServerSideAdInsertionUriBuilder setFormat(int format) {
+ return this;
+ }
+
+ public Uri build() {
+ return Uri.EMPTY;
+ }
+}
+
diff --git a/android/src/main/java/com/brentvatne/common/api/AdsProps.kt b/android/src/main/java/com/brentvatne/common/api/AdsProps.kt
index 2f5a057cb9..6cb5fb3236 100644
--- a/android/src/main/java/com/brentvatne/common/api/AdsProps.kt
+++ b/android/src/main/java/com/brentvatne/common/api/AdsProps.kt
@@ -4,38 +4,98 @@ import android.net.Uri
import android.text.TextUtils
import com.brentvatne.common.toolbox.ReactBridgeUtils
import com.facebook.react.bridge.ReadableMap
+import java.util.Objects
class AdsProps {
+ var type: String? = null
+ var streamType: String? = null
var adTagUrl: Uri? = null
var adLanguage: String? = null
+ var contentSourceId: String? = null
+ var videoId: String? = null
+ var assetKey: String? = null
+ var format: String? = null
+ var adTagParameters: Map? = null
+ var fallbackUri: String? = null
+
+ fun isCSAI(): Boolean = type == "csai" && adTagUrl != null
+ fun isDAI(): Boolean = type == "ssai"
+ fun isDAIVod(): Boolean = type == "ssai" && streamType == "vod"
+ fun isDAILive(): Boolean = type == "ssai" && streamType == "live"
- /** return true if this and src are equals */
override fun equals(other: Any?): Boolean {
if (other == null || other !is AdsProps) return false
return (
- adTagUrl == other.adTagUrl &&
- adLanguage == other.adLanguage
+ type == other.type &&
+ streamType == other.streamType &&
+ adTagUrl == other.adTagUrl &&
+ adLanguage == other.adLanguage &&
+ contentSourceId == other.contentSourceId &&
+ videoId == other.videoId &&
+ assetKey == other.assetKey &&
+ format == other.format &&
+ adTagParameters == other.adTagParameters &&
+ fallbackUri == other.fallbackUri
)
}
+ override fun hashCode(): Int =
+ Objects.hash(
+ type, streamType, adTagUrl, adLanguage, contentSourceId, videoId, assetKey, format, adTagParameters, fallbackUri
+ )
+
companion object {
+ private const val PROP_TYPE = "type"
+ private const val PROP_STREAM_TYPE = "streamType"
private const val PROP_AD_TAG_URL = "adTagUrl"
private const val PROP_AD_LANGUAGE = "adLanguage"
+ private const val PROP_CONTENT_SOURCE_ID = "contentSourceId"
+ private const val PROP_VIDEO_ID = "videoId"
+ private const val PROP_ASSET_KEY = "assetKey"
+ private const val PROP_FORMAT = "format"
+ private const val PROP_AD_TAG_PARAMETERS = "adTagParameters"
+ private const val PROP_FALLBACK_URI = "fallbackUri"
@JvmStatic
fun parse(src: ReadableMap?): AdsProps {
val adsProps = AdsProps()
if (src != null) {
+ adsProps.type = ReactBridgeUtils.safeGetString(src, PROP_TYPE)
+ adsProps.streamType = ReactBridgeUtils.safeGetString(src, PROP_STREAM_TYPE)
+
val uriString = ReactBridgeUtils.safeGetString(src, PROP_AD_TAG_URL)
- if (TextUtils.isEmpty(uriString)) {
- adsProps.adTagUrl = null
- } else {
+ if (!TextUtils.isEmpty(uriString)) {
adsProps.adTagUrl = Uri.parse(uriString)
}
+
val languageString = ReactBridgeUtils.safeGetString(src, PROP_AD_LANGUAGE)
if (!TextUtils.isEmpty(languageString)) {
adsProps.adLanguage = languageString
}
+
+ adsProps.contentSourceId = ReactBridgeUtils.safeGetString(src, PROP_CONTENT_SOURCE_ID)
+ adsProps.videoId = ReactBridgeUtils.safeGetString(src, PROP_VIDEO_ID)
+ adsProps.assetKey = ReactBridgeUtils.safeGetString(src, PROP_ASSET_KEY)
+ adsProps.format = ReactBridgeUtils.safeGetString(src, PROP_FORMAT)
+ adsProps.fallbackUri = ReactBridgeUtils.safeGetString(src, PROP_FALLBACK_URI)
+
+ if (src.hasKey(PROP_AD_TAG_PARAMETERS)) {
+ val adTagParamsMap = src.getMap(PROP_AD_TAG_PARAMETERS)
+ if (adTagParamsMap != null) {
+ val params = mutableMapOf()
+ val iterator = adTagParamsMap.keySetIterator()
+ while (iterator.hasNextKey()) {
+ val key = iterator.nextKey()
+ val value = adTagParamsMap.getString(key)
+ if (value != null) {
+ params[key] = value
+ }
+ }
+ if (params.isNotEmpty()) {
+ adsProps.adTagParameters = params
+ }
+ }
+ }
}
return adsProps
}
diff --git a/android/src/main/java/com/brentvatne/common/api/Source.kt b/android/src/main/java/com/brentvatne/common/api/Source.kt
index ef807e5458..e9cf5abc84 100644
--- a/android/src/main/java/com/brentvatne/common/api/Source.kt
+++ b/android/src/main/java/com/brentvatne/common/api/Source.kt
@@ -5,7 +5,6 @@ import android.content.ContentResolver
import android.content.Context
import android.content.res.Resources
import android.net.Uri
-import android.text.TextUtils
import com.brentvatne.common.api.DRMProps.Companion.parse
import com.brentvatne.common.toolbox.DebugLog
import com.brentvatne.common.toolbox.DebugLog.e
@@ -90,7 +89,7 @@ class Source {
*/
var sideLoadedTextTracks: SideLoadedTextTrackList? = null
- override fun hashCode(): Int = Objects.hash(uriString, uri, startPositionMs, cropStartMs, cropEndMs, extension, metadata, headers)
+ override fun hashCode(): Int = Objects.hash(uriString, uri, startPositionMs, cropStartMs, cropEndMs, extension, metadata, headers, adsProps)
/** return true if this and src are equals */
override fun equals(other: Any?): Boolean {
@@ -212,59 +211,53 @@ class Source {
fun parse(src: ReadableMap?, context: Context): Source {
val source = Source()
- if (src != null) {
- val uriString = safeGetString(src, PROP_SRC_URI, null)
- if (uriString == null || TextUtils.isEmpty(uriString)) {
- DebugLog.d(TAG, "isEmpty uri:$uriString")
- return source
- }
- var uri = Uri.parse(uriString)
- if (uri == null) {
- // return an empty source
- DebugLog.d(TAG, "Invalid uri:$uriString")
- return source
- } else if (!isValidScheme(uri.scheme)) {
- uri = getUriFromAssetId(context, uriString)
- if (uri == null) {
- // cannot find identifier of content
- DebugLog.d(TAG, "cannot find identifier")
- return source
+ if (src == null) return source
+
+ safeGetString(src, PROP_SRC_URI, null)
+ ?.takeIf { it.isNotBlank() }
+ ?.let { uriString ->
+ var uri = Uri.parse(uriString)
+
+ if (!isValidScheme(uri.scheme)) {
+ uri = getUriFromAssetId(context, uriString) ?: return source
}
+
+ source.uriString = uriString
+ source.uri = uri
}
- source.uriString = uriString
- source.uri = uri
- source.isLocalAssetFile = safeGetBool(src, PROP_SRC_IS_LOCAL_ASSET_FILE, false)
- source.isAsset = safeGetBool(src, PROP_SRC_IS_ASSET, false)
- source.startPositionMs = safeGetInt(src, PROP_SRC_START_POSITION, -1)
- source.cropStartMs = safeGetInt(src, PROP_SRC_CROP_START, -1)
- source.cropEndMs = safeGetInt(src, PROP_SRC_CROP_END, -1)
- source.contentStartTime = safeGetInt(src, PROP_SRC_CONTENT_START_TIME, -1)
- source.extension = safeGetString(src, PROP_SRC_TYPE, null)
- source.drmProps = parse(safeGetMap(src, PROP_SRC_DRM))
- source.cmcdProps = CMCDProps.parse(safeGetMap(src, PROP_SRC_CMCD))
- if (BuildConfig.USE_EXOPLAYER_IMA) {
- source.adsProps = AdsProps.parse(safeGetMap(src, PROP_SRC_ADS))
- }
- source.textTracksAllowChunklessPreparation = safeGetBool(src, PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION, true)
- source.sideLoadedTextTracks = SideLoadedTextTrackList.parse(safeGetArray(src, PROP_SRC_TEXT_TRACKS))
- source.minLoadRetryCount = safeGetInt(src, PROP_SRC_MIN_LOAD_RETRY_COUNT, 3)
- source.bufferConfig = BufferConfig.parse(safeGetMap(src, PROP_SRC_BUFFER_CONFIG))
-
- val propSrcHeadersArray = safeGetArray(src, PROP_SRC_HEADERS)
- if (propSrcHeadersArray != null) {
- if (propSrcHeadersArray.size() > 0) {
- for (i in 0 until propSrcHeadersArray.size()) {
- val current = propSrcHeadersArray.getMap(i)
- val key = current?.getString("key")
- val value = current?.getString("value")
- if (key != null && value != null) {
- source.headers[key] = value
- }
+
+ source.isLocalAssetFile = safeGetBool(src, PROP_SRC_IS_LOCAL_ASSET_FILE, false)
+ source.isAsset = safeGetBool(src, PROP_SRC_IS_ASSET, false)
+ source.startPositionMs = safeGetInt(src, PROP_SRC_START_POSITION, -1)
+ source.cropStartMs = safeGetInt(src, PROP_SRC_CROP_START, -1)
+ source.cropEndMs = safeGetInt(src, PROP_SRC_CROP_END, -1)
+ source.contentStartTime = safeGetInt(src, PROP_SRC_CONTENT_START_TIME, -1)
+ source.extension = safeGetString(src, PROP_SRC_TYPE, null)
+ source.drmProps = parse(safeGetMap(src, PROP_SRC_DRM))
+ source.cmcdProps = CMCDProps.parse(safeGetMap(src, PROP_SRC_CMCD))
+ if (BuildConfig.USE_EXOPLAYER_IMA) {
+ source.adsProps = AdsProps.parse(safeGetMap(src, PROP_SRC_ADS))
+ }
+ source.textTracksAllowChunklessPreparation = safeGetBool(src, PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION, true)
+ source.sideLoadedTextTracks = SideLoadedTextTrackList.parse(safeGetArray(src, PROP_SRC_TEXT_TRACKS))
+ source.minLoadRetryCount = safeGetInt(src, PROP_SRC_MIN_LOAD_RETRY_COUNT, 3)
+ source.bufferConfig = BufferConfig.parse(safeGetMap(src, PROP_SRC_BUFFER_CONFIG))
+
+ val propSrcHeadersArray = safeGetArray(src, PROP_SRC_HEADERS)
+ if (propSrcHeadersArray != null) {
+ if (propSrcHeadersArray.size() > 0) {
+ for (i in 0 until propSrcHeadersArray.size()) {
+ val current = propSrcHeadersArray.getMap(i)
+ val key = current?.getString("key")
+ val value = current?.getString("value")
+ if (key != null && value != null) {
+ source.headers[key] = value
}
}
}
- source.metadata = Metadata.parse(safeGetMap(src, PROP_SRC_METADATA))
}
+ source.metadata = Metadata.parse(safeGetMap(src, PROP_SRC_METADATA))
+
return source
}
diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
index f175dece5e..e16ac96d8a 100644
--- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
+++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
@@ -55,6 +55,7 @@
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSpec;
+import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.datasource.HttpDataSource;
import androidx.media3.exoplayer.DefaultLoadControl;
import androidx.media3.exoplayer.DefaultRenderersFactory;
@@ -76,6 +77,8 @@
import androidx.media3.exoplayer.drm.UnsupportedDrmException;
import androidx.media3.exoplayer.hls.HlsMediaSource;
import androidx.media3.exoplayer.ima.ImaAdsLoader;
+import androidx.media3.exoplayer.ima.ImaServerSideAdInsertionMediaSource;
+import androidx.media3.exoplayer.ima.ImaServerSideAdInsertionUriBuilder;
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo;
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil;
import androidx.media3.exoplayer.rtsp.RtspMediaSource;
@@ -125,9 +128,11 @@
import com.brentvatne.receiver.AudioBecomingNoisyReceiver;
import com.brentvatne.receiver.BecomingNoisyListener;
import com.brentvatne.receiver.PictureInPictureReceiver;
+import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.LifecycleEventListener;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.UiThreadUtil;
+import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.ThemedReactContext;
import com.google.ads.interactivemedia.v3.api.AdError;
import com.google.ads.interactivemedia.v3.api.AdErrorEvent;
@@ -182,6 +187,7 @@ public class ReactExoplayerView extends FrameLayout implements
private ExoPlayerView exoPlayerView;
private FullScreenPlayerView fullScreenPlayerView;
private ImaAdsLoader adsLoader;
+ private ImaServerSideAdInsertionMediaSource.AdsLoader daiAdsLoader;
private DataSource.Factory mediaDataSourceFactory;
private ExoPlayer player;
@@ -227,6 +233,7 @@ public class ReactExoplayerView extends FrameLayout implements
*/
private boolean isSeeking = false;
private long seekPosition = -1;
+ private boolean hasVideoEnded = false;
// Props from React
private Source source = new Source();
@@ -621,7 +628,6 @@ public boolean shouldContinueLoading(long playbackPositionUs, long bufferedDurat
private void initializePlayer() {
disableCache = ReactNativeVideoManager.Companion.getInstance().shouldDisableCache(source);
-
ReactExoplayerView self = this;
Activity activity = themedReactContext.getCurrentActivity();
// This ensures all props have been settled, to avoid async racing conditions.
@@ -631,7 +637,7 @@ private void initializePlayer() {
return;
}
try {
- if (runningSource.getUri() == null) {
+ if (runningSource.getUri() == null && !isDaiRequest(runningSource)) {
return;
}
@@ -730,13 +736,20 @@ private void initializePlayerCore(ReactExoplayerView self) {
.setEnableDecoderFallback(true)
.forceEnableMediaCodecAsynchronousQueueing();
- DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory);
+ DefaultMediaSourceFactory mediaSourceFactory;
+
+ if (isDaiRequest(source)) {
+ mediaSourceFactory = createDaiMediaSourceFactory();
+ } else {
+ mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory);
+
+ mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView());
+ }
+
if (useCache && !disableCache) {
mediaSourceFactory.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)));
}
- mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView());
-
player = new ExoPlayer.Builder(getContext(), renderersFactory)
.setTrackSelector(self.trackSelector)
.setBandwidthMeter(bandwidthMeter)
@@ -830,6 +843,11 @@ private DrmSessionManager buildDrmSessionManager(UUID uuid, DRMProps drmProps) t
}
private void initializePlayerSource(Source runningSource) {
+ if (isDaiRequest(runningSource)) {
+ initializeDaiSource(runningSource);
+ return;
+ }
+
if (runningSource.getUri() == null) {
return;
}
@@ -1224,6 +1242,12 @@ private void releasePlayer() {
adsLoader.release();
adsLoader = null;
}
+
+ if (daiAdsLoader != null) {
+ daiAdsLoader.release();
+ daiAdsLoader = null;
+ }
+
progressHandler.removeMessages(SHOW_PROGRESS);
audioBecomingNoisyReceiver.removeListener();
pictureInPictureReceiver.removeListener();
@@ -1411,6 +1435,7 @@ public void onEvents(@NonNull Player player, Player.Events events) {
break;
case Player.STATE_READY:
text += "ready";
+ hasVideoEnded = false;
eventEmitter.onReadyForDisplay.invoke();
onBuffering(false);
clearProgressMessageHandler(); // ensure there is no other message
@@ -1429,7 +1454,10 @@ public void onEvents(@NonNull Player player, Player.Events events) {
case Player.STATE_ENDED:
text += "ended";
updateProgress();
- eventEmitter.onVideoEnd.invoke();
+ if (!hasVideoEnded) {
+ hasVideoEnded = true;
+ eventEmitter.onVideoEnd.invoke();
+ }
onStopPlayback();
setKeepScreenOn(false);
break;
@@ -1819,7 +1847,10 @@ public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @N
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION
&& player.getRepeatMode() == Player.REPEAT_MODE_ONE) {
updateProgress();
- eventEmitter.onVideoEnd.invoke();
+ if (!hasVideoEnded) {
+ hasVideoEnded = true;
+ eventEmitter.onVideoEnd.invoke();
+ }
}
}
@@ -2007,7 +2038,7 @@ public void onCues(CueGroup cueGroup) {
}
public void setSrc(Source source) {
- if (source.getUri() != null) {
+ if (source.getUri() != null || isDaiRequest(source)) {
clearResumePosition();
boolean isSourceEqual = source.isEquals(this.source);
hasDrmFailed = false;
@@ -2030,6 +2061,7 @@ public void setSrc(Source source) {
}
if (!isSourceEqual) {
+ hasVideoEnded = false;
playerNeedsSource = true;
initializePlayer();
}
@@ -2732,10 +2764,180 @@ public void onAdError(AdErrorEvent adErrorEvent) {
"type", String.valueOf(error.getErrorType())
);
eventEmitter.onReceiveAdEvent.invoke("ERROR", errMap);
+
+ handleDaiBackupStream();
}
public void setControlsStyles(ControlsConfig controlsStyles) {
controlsConfig = controlsStyles;
refreshControlsStyles();
}
+
+ /**
+ * Checks if the source is a DAI (Dynamic Ad Insertion) request.
+ *
+ * A DAI request is identified by either:
+ * - VOD: both contentSourceId and videoId are present
+ * - Live: assetKey is present
+ *
+ * @param source The source to check
+ * @return true if the source is a DAI request, false otherwise
+ */
+ private boolean isDaiRequest(Source source) {
+ if (source == null || source.getAdsProps() == null) {
+ return false;
+ }
+ return source.getAdsProps().isDAI();
+ }
+
+ /**
+ * Creates and configures a server-side ad insertion (SSAI) AdsLoader for DAI.
+ *
+ * @return The configured IMA server-side ad insertion AdsLoader
+ */
+ private ImaServerSideAdInsertionMediaSource.AdsLoader createAdsLoader() {
+ ImaServerSideAdInsertionMediaSource.AdsLoader.Builder adsLoaderBuilder =
+ new ImaServerSideAdInsertionMediaSource.AdsLoader.Builder(getContext(), exoPlayerView.getPlayerView())
+ .setAdEventListener(this)
+ .setAdErrorListener(this);
+
+ return adsLoaderBuilder.build();
+ }
+
+ /**
+ * Creates and configures a media source factory for DAI playback.
+ *
+ * @return The configured DefaultMediaSourceFactory with DAI support
+ */
+ private DefaultMediaSourceFactory createDaiMediaSourceFactory() {
+ daiAdsLoader = createAdsLoader();
+
+ DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(getContext());
+ DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory);
+
+ ImaServerSideAdInsertionMediaSource.Factory adsMediaSourceFactory =
+ new ImaServerSideAdInsertionMediaSource.Factory(daiAdsLoader, mediaSourceFactory);
+
+ mediaSourceFactory.setServerSideAdInsertionMediaSourceFactory(adsMediaSourceFactory);
+
+ return mediaSourceFactory;
+ }
+
+ /**
+ * Initializes the player for DAI source.
+ *
+ * Requests the DAI stream and completes player initialization.
+ *
+ * @param runningSource The source containing DAI properties
+ */
+ private void initializeDaiSource(Source runningSource) {
+ if (player == null) {
+ DebugLog.w(TAG, "Player is null in initializeDaiSource, skipping DAI initialization");
+ return;
+ }
+
+ requestDaiStream(runningSource);
+
+ player.prepare();
+ playerNeedsSource = false;
+
+ eventEmitter.onVideoLoadStart.invoke();
+ loadVideoStarted = true;
+
+ finishPlayerInitialization();
+ }
+
+ /**
+ * Requests a DAI stream from Google IMA using the ExoPlayer IMA extension.
+ *
+ * Builds an SSAI URI based on the provided parameters and sets it on the player.
+ * Supports both VOD (contentSourceId + videoId) and Live (assetKey) streams.
+ *
+ * @param runningSource The source containing DAI properties
+ */
+ private void requestDaiStream(Source runningSource) {
+ if (daiAdsLoader == null) {
+ eventEmitter.onVideoError.invoke("DaiAdsLoader is null", null, "DAI_ADS_LOADER_NULL_ERROR");
+ return;
+ }
+
+ daiAdsLoader.setPlayer(player);
+
+ AdsProps adsProps = runningSource.getAdsProps();
+ int streamFormat = "dash".equalsIgnoreCase(adsProps.getFormat()) ? CONTENT_TYPE_DASH : CONTENT_TYPE_HLS;
+
+ try {
+ Uri.Builder uriBuilder;
+
+ if (adsProps.isDAILive()) {
+ uriBuilder = new ImaServerSideAdInsertionUriBuilder()
+ .setAssetKey(adsProps.getAssetKey())
+ .setFormat(streamFormat)
+ .build()
+ .buildUpon();
+ } else if (adsProps.isDAIVod()) {
+ uriBuilder = new ImaServerSideAdInsertionUriBuilder()
+ .setContentSourceId(adsProps.getContentSourceId())
+ .setVideoId(adsProps.getVideoId())
+ .setFormat(streamFormat)
+ .build()
+ .buildUpon();
+ } else {
+ throw new IllegalArgumentException("Either assetKey (for live) or contentSourceId+videoId (for VOD) must be provided");
+ }
+
+ Map adTagParameters = adsProps.getAdTagParameters();
+ if (adTagParameters != null && !adTagParameters.isEmpty()) {
+ for (Map.Entry entry : adTagParameters.entrySet()) {
+ uriBuilder.appendQueryParameter(entry.getKey(), entry.getValue());
+ }
+ }
+
+ Uri ssaiUri = uriBuilder.build();
+ MediaItem ssaiMediaItem = MediaItem.fromUri(ssaiUri);
+
+ player.setMediaItem(ssaiMediaItem);
+ } catch (Exception e) {
+ eventEmitter.onVideoError.invoke("DAI stream request failed: " + e.getMessage(), e, "DAI_REQUEST_ERROR");
+ handleDaiBackupStream();
+ }
+ }
+
+ /**
+ * Handles fallback to backup stream when DAI stream fails.
+ *
+ * If a backup stream URI is available in the DAI properties, it cleans up DAI resources
+ * and switches to the backup stream.
+ *
+ * @return true if backup stream was successfully used, false otherwise
+ */
+ private boolean handleDaiBackupStream() {
+ if (source == null || source.getAdsProps() == null) {
+ return false;
+ }
+
+ String fallbackStreamUri = source.getAdsProps().getFallbackUri();
+ if (fallbackStreamUri == null || fallbackStreamUri.isEmpty()) {
+ return false;
+ }
+
+ DebugLog.d(TAG, "DAI stream error occurred, falling back to backup stream URI: " + fallbackStreamUri);
+
+ WritableMap backupSourceMap = Arguments.createMap();
+ backupSourceMap.putString("uri", fallbackStreamUri);
+ backupSourceMap.putBoolean("isNetwork", true);
+
+ Source backupSource = Source.parse(backupSourceMap, themedReactContext);
+ if (backupSource == null || backupSource.getUri() == null) {
+ return false;
+ }
+
+ if (daiAdsLoader != null) {
+ daiAdsLoader.setPlayer(null);
+ }
+
+ setSrc(backupSource);
+
+ return true;
+ }
}
diff --git a/docs/assets/baners/bgupload-sdk-banner.png b/docs/assets/baners/bgupload-sdk-banner.png
new file mode 100644
index 0000000000..dbb1c02cd6
Binary files /dev/null and b/docs/assets/baners/bgupload-sdk-banner.png differ
diff --git a/docs/assets/baners/sdk-banner.png b/docs/assets/baners/offline-sdk-banner.png
similarity index 100%
rename from docs/assets/baners/sdk-banner.png
rename to docs/assets/baners/offline-sdk-banner.png
diff --git a/docs/pages/_meta.json b/docs/pages/_meta.json
index f79a9c87e7..ba08a31be8 100644
--- a/docs/pages/_meta.json
+++ b/docs/pages/_meta.json
@@ -20,7 +20,7 @@
"video_offline_sdk": {
"title": "Offline Video SDK",
"newWindow": true,
- "href": "https://www.thewidlarzgroup.com/offline-video-sdk/?utm_source=rnv&utm_medium=docs&utm_campaign=sidebar&utm_id=offline-video-sdk-button"
+ "href": "https://sdk.thewidlarzgroup.com/offline-video/?utm_source=rnv&utm_medium=docs&utm_campaign=sidebar&utm_id=offline-video-sdk-button"
},
"enterprise_support": {
"title": "Enterprise Support",
diff --git a/docs/pages/component/ads.md b/docs/pages/component/ads.md
index 0a9cb330d0..e08b035fb5 100644
--- a/docs/pages/component/ads.md
+++ b/docs/pages/component/ads.md
@@ -4,14 +4,36 @@
`react-native-video` includes built-in support for Google IMA SDK on Android and iOS. To enable it, refer to the [installation section](/installation).
+The IMA SDK supports two types of ad insertion:
+
+1. **Client-Side Ad Insertion (CSAI)** – Ads are inserted client-side using VAST tags
+2. **Server-Side Ad Insertion (SSAI)** – Server-side ad insertion where ads are stitched into the stream
+
+Both ad types are configured through the unified `ad` property in the source configuration, using the `type` field to specify which mode to use.
+
+---
+
+## Client-Side Ad Insertion (CSAI)
+
+CSAI inserts ads client-side using VAST (Video Ad Serving Template) tags. Ads are requested and played during video playback, with the player handling ad breaks and transitions.
+
### Usage
-To use AVOD (Ad-Supported Video on Demand), pass the `adTagUrl` prop to the `Video` component. The `adTagUrl` should be a VAST-compliant URI.
+To use CSAI, configure the `ad` property with `type: 'csai'` and provide an `adTagUrl`. The `adTagUrl` should be a VAST-compliant URI.
#### Example:
```jsx
-adTagUrl="https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostoptimizedpodbumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator="
+
```
> **Note:** Video ads cannot start when Picture-in-Picture (PiP) mode is active on iOS. More details are available in the [Google IMA SDK Docs](https://developers.google.com/interactive-media-ads/docs/sdks/ios/client-side/picture_in_picture?hl=en#starting_ads). If you are using custom controls, hide the PiP button when receiving the `STARTED` event from `onReceiveAdEvent` and show it again when receiving the `ALL_ADS_COMPLETED` event.
@@ -23,21 +45,146 @@ To receive events from the IMA SDK, pass the `onReceiveAdEvent` prop to the `Vid
#### Example:
```jsx
-...
-onReceiveAdEvent={event => console.log(event)}
-...
+