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 - - Offline SDK Preview + + Offline SDK Preview ### 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)** +--- + + Offline SDK Preview + + +### 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=" +