feat!: add DAI support (#4816)
* feat: add DAI support * refactor!: unify CSAI and DAI configuration into single ad prop * fix(android): remove broken wait() in DAI initialization * feat(android): support HLS/DASH format in DAI * fix: lint formatting issues * refactor(android): simplify URI parsing * refactor(android): rename backupStreamUri to fallbackStreamUri for consistency * refactor: restore deleted comment * refactor: remove expo-dai example app * feat: add streamType field * refactor: simplify DAI config normalization * refactor: rename type value from dai to ssai * docs: replace DAI terminology with SSAI * docs: change ssai desc Co-authored-by: Kamil Moskała <91079590+moskalakamil@users.noreply.github.com> * fix(ios): conditional background playback for DAI based on PiP settings * fix: remove unused import --------- Co-authored-by: Maciej Budzinski <maciej.budzinski.93@gmail.com> Co-authored-by: Kamil Moskała <91079590+moskalakamil@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String>? = 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 (
|
||||
type == other.type &&
|
||||
streamType == other.streamType &&
|
||||
adTagUrl == other.adTagUrl &&
|
||||
adLanguage == other.adLanguage
|
||||
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<String, String>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,27 +211,21 @@ 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
|
||||
}
|
||||
if (src == null) return source
|
||||
|
||||
safeGetString(src, PROP_SRC_URI, null)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { uriString ->
|
||||
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 (!isValidScheme(uri.scheme)) {
|
||||
uri = getUriFromAssetId(context, uriString) ?: return source
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -264,7 +257,7 @@ class Source {
|
||||
}
|
||||
}
|
||||
source.metadata = Metadata.parse(safeGetMap(src, PROP_SRC_METADATA))
|
||||
}
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import androidx.media3.common.text.CueGroup;
|
||||
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.HttpMediaDrmCallback;
|
||||
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.react.ReactNativeVideoManager;
|
||||
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;
|
||||
@@ -622,7 +628,6 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
|
||||
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.
|
||||
@@ -632,7 +637,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (runningSource.getUri() == null) {
|
||||
if (runningSource.getUri() == null && !isDaiRequest(runningSource)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -731,13 +736,20 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
.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)
|
||||
@@ -831,6 +843,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
}
|
||||
|
||||
private void initializePlayerSource(Source runningSource) {
|
||||
if (isDaiRequest(runningSource)) {
|
||||
initializeDaiSource(runningSource);
|
||||
return;
|
||||
}
|
||||
|
||||
if (runningSource.getUri() == null) {
|
||||
return;
|
||||
}
|
||||
@@ -1225,6 +1242,12 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
adsLoader.release();
|
||||
adsLoader = null;
|
||||
}
|
||||
|
||||
if (daiAdsLoader != null) {
|
||||
daiAdsLoader.release();
|
||||
daiAdsLoader = null;
|
||||
}
|
||||
|
||||
progressHandler.removeMessages(SHOW_PROGRESS);
|
||||
audioBecomingNoisyReceiver.removeListener();
|
||||
pictureInPictureReceiver.removeListener();
|
||||
@@ -2015,7 +2038,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
}
|
||||
|
||||
public void setSrc(Source source) {
|
||||
if (source.getUri() != null) {
|
||||
if (source.getUri() != null || isDaiRequest(source)) {
|
||||
clearResumePosition();
|
||||
boolean isSourceEqual = source.isEquals(this.source);
|
||||
hasDrmFailed = false;
|
||||
@@ -2741,10 +2764,180 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
"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<String, String> adTagParameters = adsProps.getAdTagParameters();
|
||||
if (adTagParameters != null && !adTagParameters.isEmpty()) {
|
||||
for (Map.Entry<String, String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="
|
||||
<Video
|
||||
source={{
|
||||
uri: 'https://example.com/video.mp4',
|
||||
ad: {
|
||||
type: 'csai',
|
||||
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)}
|
||||
...
|
||||
<Video
|
||||
onReceiveAdEvent={(event) => console.log(event)}
|
||||
// ... other props
|
||||
/>
|
||||
```
|
||||
|
||||
### Localization
|
||||
|
||||
To change the language of the IMA SDK, pass the `adLanguage` prop to the `Video` component. The list of supported languages is available [here](https://developers.google.com/interactive-media-ads/docs/sdks/android/client-side/localization#locale-codes).
|
||||
To change the language of the IMA SDK, pass the `adLanguage` prop within the `ad` configuration. The list of supported languages is available [here](https://developers.google.com/interactive-media-ads/docs/sdks/android/client-side/localization#locale-codes).
|
||||
|
||||
- By default, **iOS** uses the system language, and **Android** defaults to `en` (English).
|
||||
|
||||
#### Example:
|
||||
|
||||
```jsx
|
||||
...
|
||||
adLanguage="fr"
|
||||
...
|
||||
<Video
|
||||
source={{
|
||||
uri: 'https://example.com/video.mp4',
|
||||
ad: {
|
||||
type: 'csai',
|
||||
adTagUrl: 'https://example.com/adtag',
|
||||
adLanguage: 'fr',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Ad Insertion (SSAI)
|
||||
|
||||
SSAI (Server-Side Ad Insertion) is a server-side ad insertion solution where ads are stitched into the video stream before it reaches the player. This provides a seamless viewing experience with no playback interruptions, as the stream appears as a single continuous video.
|
||||
|
||||
Currently, we support **Google IMA DAI**
|
||||
|
||||
SSAI is ideal for:
|
||||
|
||||
- Live streaming with ad breaks
|
||||
- VOD content with dynamic ad insertion
|
||||
- Scenarios where you want a seamless, uninterrupted viewing experience
|
||||
|
||||
### Usage
|
||||
|
||||
To use SSAI, configure the `ad` property with `type: 'ssai'` within the `source` prop. SSAI supports both Video On Demand (VOD) and Live streaming.
|
||||
|
||||
#### VOD Example:
|
||||
|
||||
```jsx
|
||||
<Video
|
||||
source={{
|
||||
ad: {
|
||||
type: 'ssai',
|
||||
streamType: 'vod',
|
||||
contentSourceId: '2548831',
|
||||
videoId: 'tears-of-steel',
|
||||
adTagParameters: {
|
||||
custom_param: 'value',
|
||||
},
|
||||
fallbackUri: 'https://example.com/backup-stream.m3u8',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Live Example:
|
||||
|
||||
```jsx
|
||||
<Video
|
||||
source={{
|
||||
ad: {
|
||||
type: 'ssai',
|
||||
streamType: 'live',
|
||||
assetKey: 'c-rArva4ShKVIAkNfy6HUQ',
|
||||
adTagParameters: {
|
||||
custom_param: 'value',
|
||||
},
|
||||
fallbackUri: 'https://example.com/backup-stream.m3u8',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
For VOD streams, you must provide:
|
||||
|
||||
- `contentSourceId` – The content source ID
|
||||
- `videoId` – The video ID
|
||||
|
||||
For Live streams, you must provide:
|
||||
|
||||
- `assetKey` – The asset key for the live stream
|
||||
|
||||
Optional properties:
|
||||
|
||||
- `format` – Stream format: `'hls'` (default) or `'dash'`. Android only - iOS automatically detects the format.
|
||||
- `adTagParameters` – Custom key-value pairs to pass as ad tag parameters to the IMA SDK. For a list of supported Ad Manager ad tag parameters, see the [Google Ad Manager documentation](https://support.google.com/admanager/answer/7320899?hl=en#npa).
|
||||
- `fallbackUri` – Fallback stream URI. If the SSAI stream fails to load, the player will automatically fall back to this URI
|
||||
|
||||
> **Note:** The `streamType` field (`'vod'` or `'live'`) is required to specify the type of SSAI stream.
|
||||
|
||||
### Events
|
||||
|
||||
SSAI uses the same `onReceiveAdEvent` prop as CSAI to report ad-related events. The full list of supported events is available [here](https://github.com/TheWidlarzGroup/react-native-video/blob/master/src/types/Ads.ts).
|
||||
|
||||
#### Example:
|
||||
|
||||
```jsx
|
||||
<Video
|
||||
source={{
|
||||
ad: {
|
||||
type: 'ssai',
|
||||
streamType: 'vod',
|
||||
contentSourceId: '2548831',
|
||||
videoId: 'tears-of-steel',
|
||||
},
|
||||
}}
|
||||
onReceiveAdEvent={(event) => console.log(event)}
|
||||
// ... other props
|
||||
/>
|
||||
```
|
||||
|
||||
For more details on ad configuration properties, see the [props documentation](/component/props#ad).
|
||||
|
||||
### Fallback Stream
|
||||
|
||||
If the SSAI stream fails to load and a `fallbackUri` is provided, the player will automatically fall back to the fallback stream. This ensures playback continuity even when SSAI services are unavailable.
|
||||
|
||||
### Example App
|
||||
|
||||
For testing and experimenting with SSAI, you can use the `expo-dai` example app located in the `examples/expo-dai` directory. This example app demonstrates SSAI functionality for both VOD and Live streaming scenarios.
|
||||
|
||||
### Differences from CSAI
|
||||
|
||||
| Feature | CSAI | SSAI |
|
||||
| ---------------------- | ------------------------------------- | ------------------------------------------ |
|
||||
| Ad insertion | Client-side | Server-side |
|
||||
| Playback interruptions | Possible during ad breaks | Seamless, no interruptions |
|
||||
| Stream format | Original video + separate ad requests | Single unified stream with ads |
|
||||
| Use case | VOD with pre-defined ad breaks | Live and VOD with server-side ad insertion |
|
||||
| Configuration | `source.ad` with `type: 'csai'` | `source.ad` with `type: 'ssai'` |
|
||||
|
||||
---
|
||||
|
||||
@@ -18,7 +18,8 @@ Sets the VAST URI to play AVOD ads.
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
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="
|
||||
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:** You need to enable IMA SDK in the Gradle or Podfile – see [Enable Client-Side Ads Insertion](/installation).
|
||||
@@ -68,7 +69,7 @@ Indicates whether the player should automatically delay playback to minimize sta
|
||||
Adjusts the buffer settings. This prop takes an object with one or more of the following properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------------------------------|--------|-----------------------------------------------------------------------------------------------|
|
||||
| --------------------------------- | ------ | --------------------------------------------------------------------------------- |
|
||||
| minBufferMs | number | Minimum duration (ms) the player will attempt to keep buffered. |
|
||||
| maxBufferMs | number | Maximum duration (ms) the player will attempt to buffer. |
|
||||
| bufferForPlaybackMs | number | Duration (ms) that must be buffered before playback starts or resumes. |
|
||||
@@ -83,7 +84,7 @@ Adjusts the buffer settings. This prop takes an object with one or more of the f
|
||||
#### Live Buffer Configurations
|
||||
|
||||
| Property | Type | Description |
|
||||
|-----------------|--------|-----------------------------------------------------------------------------|
|
||||
| ---------------- | ------ | ------------------------------------------------------------------ |
|
||||
| maxPlaybackSpeed | number | Maximum playback speed for catching up to target live offset. |
|
||||
| minPlaybackSpeed | number | Minimum playback speed for falling back to target live offset. |
|
||||
| maxOffsetMs | number | Maximum allowed live offset. The player won’t exceed this limit. |
|
||||
@@ -131,7 +132,7 @@ Configures the buffering and data loading strategy.
|
||||
Provides a custom chapter source for tvOS. This prop takes an array of objects with the following properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|--------|-----------------------------------------------------------------------------|
|
||||
| --------- | ------- | ------------------------------------------------------------------------------ |
|
||||
| title | string | The title of the chapter. |
|
||||
| startTime | number | The start time of the chapter (seconds). |
|
||||
| endTime | number | The end time of the chapter (seconds). |
|
||||
@@ -172,7 +173,7 @@ See [Useful Side Projects](/projects).
|
||||
Adjust the control styles. This prop is needed only if `controls={true}` and is an object. See the supported properties below.
|
||||
|
||||
| Property | Type | Description |
|
||||
|-------------------------------------|---------|---------------------------------------------------------------------------------------------|
|
||||
| ----------------------------------- | ------- | ------------------------------------------------------------------- |
|
||||
| hidePosition | boolean | Hides the position indicator. Default is `false`. |
|
||||
| hidePlayPause | boolean | Hides the play/pause button. Default is `false`. |
|
||||
| hideForward | boolean | Hides the forward button. Default is `false`. |
|
||||
@@ -232,7 +233,7 @@ Enables detailed logging.
|
||||
> Do not use this in production builds.
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------- | ------- | -------------------------------------------- |
|
||||
| -------- | ------- | ----------------------------------------- |
|
||||
| `enable` | boolean | Enables verbose logs. Default is `false`. |
|
||||
| `thread` | boolean | Displays logs with thread information. |
|
||||
|
||||
@@ -339,7 +340,7 @@ Determines whether to enter Picture-in-Picture (PiP) mode when the user leaves t
|
||||
Applies a video filter.
|
||||
|
||||
| FilterType | Description |
|
||||
|-----------------------------|-------------------------|
|
||||
| ------------------ | --------------------- |
|
||||
| `NONE (default)` | No filter |
|
||||
| `INVERT` | CIColorInvert |
|
||||
| `MONOCHROME` | CIColorMonochrome |
|
||||
@@ -358,6 +359,7 @@ Applies a video filter.
|
||||
| `SEPIA` | CISepiaTone |
|
||||
|
||||
> **Notes:**
|
||||
>
|
||||
> 1. Using a filter may increase CPU usage.
|
||||
> 2. Saving a filtered video and reloading it is a workaround for performance issues.
|
||||
> 3. Filters are not supported on HLS playlists.
|
||||
@@ -551,9 +553,10 @@ An image to display while the video is loading.
|
||||
|
||||
```javascript
|
||||
<Video>
|
||||
poster={{
|
||||
source: { uri: "https://baconmockup.com/300/200/" },
|
||||
resizeMode: "cover",
|
||||
poster=
|
||||
{{
|
||||
source: {uri: 'https://baconmockup.com/300/200/'},
|
||||
resizeMode: 'cover',
|
||||
}}
|
||||
</Video>
|
||||
```
|
||||
@@ -646,7 +649,8 @@ interface ReactVideoRenderLoaderProps {
|
||||
|
||||
```javascript
|
||||
<Video>
|
||||
renderLoader={() => (
|
||||
renderLoader=
|
||||
{() => (
|
||||
<View>
|
||||
<Text>Custom Loader</Text>
|
||||
</View>
|
||||
@@ -730,7 +734,7 @@ selectedTextTrack={{
|
||||
```
|
||||
|
||||
| Type | Value | Description |
|
||||
| ------------------ | ------ | ----------------------------------------------------------------------------- |
|
||||
| ------------------ | ------ | ----------------------------------------------------------------------- |
|
||||
| "system" (default) | N/A | Display captions only if the system preference for captions is enabled. |
|
||||
| "disabled" | N/A | Don’t display a text track. |
|
||||
| "title" | string | Display the text track with the specified title, e.g., "French 1". |
|
||||
@@ -755,7 +759,7 @@ selectedVideoTrack={{
|
||||
```
|
||||
|
||||
| Type | Value | Description |
|
||||
| ---------------- | ------ | ---------------------------------------------------------------------------- |
|
||||
| ---------------- | ------ | ------------------------------------------------------------------------------ |
|
||||
| "auto" (default) | N/A | Let the player determine the best track using ABR. |
|
||||
| "disabled" | N/A | Turn off video. |
|
||||
| "resolution" | number | Play the video track with the specified height, e.g., 480 for the 480p stream. |
|
||||
@@ -954,20 +958,82 @@ source={{
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
|
||||
Sets the ad configuration.
|
||||
Sets the ad configuration. Supports both Client-Side Ad Insertion (CSAI) and Server-Side Ad Insertion (SSAI) through the unified `type` field.
|
||||
|
||||
**Example:**
|
||||
**CSAI (Client-Side Ad Insertion)**
|
||||
|
||||
For client-side ads using VAST tags:
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ---------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `'csai'` |
|
||||
| `adTagUrl` | string | Yes | VAST-compliant ad tag URL |
|
||||
| `adLanguage` | string | Optional | Language code for the IMA SDK (e.g., `'en'`, `'fr'`) |
|
||||
|
||||
**CSAI Example:**
|
||||
|
||||
```javascript
|
||||
source={{
|
||||
uri: 'https://example.com/video.mp4',
|
||||
ad: {
|
||||
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="
|
||||
adLanguage="fr"
|
||||
type: 'csai',
|
||||
adTagUrl: 'https://pubads.g.doubleclick.net/gampad/ads?...',
|
||||
adLanguage: 'fr'
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
**SSAI (Server-Side Ad Insertion)**
|
||||
|
||||
For server-side ad insertion. Supports both VOD and Live streaming.
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ----------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `'ssai'` |
|
||||
| `streamType` | string | Yes | Must be `'vod'` for VOD streams or `'live'` for live streams |
|
||||
| `contentSourceId` | string | VOD only | The content source ID for VOD streams. Required for VOD, must not be used with `assetKey`. |
|
||||
| `videoId` | string | VOD only | The video ID for VOD streams. Required for VOD, must not be used with `assetKey`. |
|
||||
| `assetKey` | string | Live only | The asset key for live streams. Required for Live, must not be used with `contentSourceId` or `videoId`. |
|
||||
| `adTagParameters` | object | Optional | Custom key-value pairs to pass as ad tag parameters to the IMA SDK. For a list of supported Ad Manager ad tag parameters, see the [Google Ad Manager documentation](https://support.google.com/admanager/answer/7320899). |
|
||||
| `fallbackUri` | string | Optional | Fallback stream URI. If the SSAI stream fails to load, the player will automatically fall back to this URI. |
|
||||
| `adLanguage` | string | Optional | Language code for the IMA SDK (e.g., `'en'`, `'fr'`) |
|
||||
|
||||
**SSAI VOD Example:**
|
||||
|
||||
```javascript
|
||||
source={{
|
||||
ad: {
|
||||
type: 'ssai',
|
||||
streamType: 'vod',
|
||||
contentSourceId: '2548831',
|
||||
videoId: 'tears-of-steel',
|
||||
adTagParameters: {
|
||||
'custom_param': 'value'
|
||||
},
|
||||
fallbackUri: 'https://example.com/backup-stream.m3u8'
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
**SSAI Live Example:**
|
||||
|
||||
```javascript
|
||||
source={{
|
||||
ad: {
|
||||
type: 'ssai',
|
||||
streamType: 'live',
|
||||
assetKey: 'c-rArva4ShKVIAkNfy6HUQ',
|
||||
adTagParameters: {
|
||||
'custom_param': 'value'
|
||||
},
|
||||
fallbackUri: 'https://example.com/backup-stream.m3u8'
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
See: [ads](./ads.md) for more information.
|
||||
|
||||
Note: You need to enable IMA SDK in the Gradle or Pod file - [enable client-side ads insertion](/installation).
|
||||
Note: You need to enable IMA SDK in the Gradle or Podfile – see [Enable Client-Side Ads Insertion](/installation).
|
||||
|
||||
#### `contentStartTime`
|
||||
|
||||
@@ -1000,7 +1066,7 @@ source={{
|
||||
Adjust the buffer settings. This prop takes an object with one or more of the properties listed below.
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------------------------------|--------|------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| --------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| minBufferMs | number | Minimum duration of media that the player will attempt to buffer at all times, in milliseconds. |
|
||||
| maxBufferMs | number | Maximum duration of media that the player will attempt to buffer, in milliseconds. |
|
||||
| bufferForPlaybackMs | number | Duration of media that must be buffered for playback to start or resume following a user action, in milliseconds. |
|
||||
@@ -1039,7 +1105,7 @@ Load one or more "sidecar" text tracks. This takes an array of objects represent
|
||||
> ⚠️ This feature does not work with HLS playlists (e.g., m3u8) on iOS.
|
||||
|
||||
| Property | Description |
|
||||
|----------|---------------------------------------------------------------------------------------------------------------|
|
||||
| -------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| title | Descriptive name for the track. |
|
||||
| language | 2-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) representing the language. |
|
||||
| type | Mime type of the track. Supports `TextTrackType.SUBRIP`, `TextTrackType.TTML`, `TextTrackType.VTT`. |
|
||||
@@ -1054,18 +1120,18 @@ import { TextTrackType } from 'react-native-video';
|
||||
|
||||
textTracks = [
|
||||
{
|
||||
title: "English CC",
|
||||
language: "en",
|
||||
title: 'English CC',
|
||||
language: 'en',
|
||||
type: TextTrackType.VTT, // "text/vtt"
|
||||
uri: "https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_en.vtt"
|
||||
uri: 'https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_en.vtt',
|
||||
},
|
||||
{
|
||||
title: "Spanish Subtitles",
|
||||
language: "es",
|
||||
title: 'Spanish Subtitles',
|
||||
language: 'es',
|
||||
type: TextTrackType.SUBRIP, // "application/x-subrip"
|
||||
uri: "https://durian.blender.org/wp-content/content/subtitles/sintel_es.srt"
|
||||
}
|
||||
]
|
||||
uri: 'https://durian.blender.org/wp-content/content/subtitles/sintel_es.srt',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1075,7 +1141,7 @@ textTracks=[
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
|
||||
| Property | Platform | Description | Platforms |
|
||||
| ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| -------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| fontSize | Android | Adjust the font size of the subtitles. Default: font size of the device | Android |
|
||||
| paddingTop | Android | Adjust the top padding of the subtitles. Default: 0 | Android |
|
||||
| paddingBottom | Android | Adjust the bottom padding of the subtitles. Default: 0 | Android |
|
||||
@@ -1101,9 +1167,9 @@ So there is a second view, the video view.
|
||||
|
||||
Subtitles are managed in a third view.
|
||||
|
||||
* When `subtitlesFollowVideo` is set to true, the subtitle view will adapt to the video view.
|
||||
- When `subtitlesFollowVideo` is set to true, the subtitle view will adapt to the video view.
|
||||
If the video is displayed out of screen, the subtitles may also be displayed out of screen.
|
||||
* When `subtitlesFollowVideo` is set to false, the subtitle view will adapt to the main view.
|
||||
- When `subtitlesFollowVideo` is set to false, the subtitle view will adapt to the main view.
|
||||
If the video is displayed out of screen, the subtitles may still remain visible within the main view.
|
||||
|
||||
This prop can be changed at runtime.
|
||||
@@ -1122,7 +1188,7 @@ Load one or more "sidecar" text tracks. This takes an array of objects represent
|
||||
> ⚠️ This feature does not work with HLS playlists (e.g., m3u8) on iOS.
|
||||
|
||||
| Property | Description |
|
||||
|----------|-------------|
|
||||
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| title | Descriptive name for the track |
|
||||
| language | 2-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) representing the language |
|
||||
| type | Mime type of the track (TextTrackType.SUBRIP - SubRip (.srt), TextTrackType.TTML - TTML (.ttml), TextTrackType.VTT - WebVTT (.vtt)). iOS only supports VTT, Android supports all 3. |
|
||||
@@ -1240,6 +1306,7 @@ useTextureView can only be set at the same time you're setting the source.
|
||||
Allows explicitly specifying the view type.
|
||||
This flag replaces `useSecureView` and `useTextureView` fields.
|
||||
There are 3 available values:
|
||||
|
||||
- 'textureView': The video is rendered in a texture view. It allows mapping the view on a texture (useful for 3D).
|
||||
DRM playback is not supported on textureView. If the DRM prop is provided, the surface will be transformed into a SurfaceView.
|
||||
- 'surfaceView' (default): The video is rendered in a surface, taking fewer resources to render.
|
||||
@@ -1270,7 +1337,7 @@ For detailed information about CMCD, please refer to the [CTA-5004 Final Specifi
|
||||
When providing an object, you can configure the following properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
|------------|------------|------------------------------------------------|
|
||||
| --------- | ---------- | ------------------------------------------------- |
|
||||
| `mode` | `CmcdMode` | The mode for sending CMCD data |
|
||||
| `request` | `CmcdData` | Custom key-value pairs for the request object |
|
||||
| `session` | `CmcdData` | Custom key-value pairs for the session object |
|
||||
@@ -1280,11 +1347,14 @@ When providing an object, you can configure the following properties:
|
||||
Note: The `mode` property defaults to `CmcdMode.MODE_QUERY_PARAMETER` if not specified.
|
||||
|
||||
#### `CmcdMode`
|
||||
|
||||
CmcdMode is an enum that defines how CMCD data should be sent:
|
||||
|
||||
- `CmcdMode.MODE_REQUEST_HEADER` (0) - Send CMCD data in the HTTP request headers.
|
||||
- `CmcdMode.MODE_QUERY_PARAMETER` (1) - Send CMCD data as query parameters in the URL.
|
||||
|
||||
#### `CmcdData`
|
||||
|
||||
CmcdData is a type representing custom key-value pairs for CMCD data. It's defined as:
|
||||
|
||||
```typescript
|
||||
@@ -1302,19 +1372,19 @@ Custom key names MUST include a hyphenated prefix to prevent namespace collision
|
||||
cmcd: {
|
||||
mode: CmcdMode.MODE_QUERY_PARAMETER,
|
||||
request: {
|
||||
'com-custom-key': 'custom-value'
|
||||
'com-custom-key': 'custom-value',
|
||||
},
|
||||
session: {
|
||||
sid: 'session-id'
|
||||
sid: 'session-id',
|
||||
},
|
||||
object: {
|
||||
br: '3000',
|
||||
d: '4000'
|
||||
d: '4000',
|
||||
},
|
||||
status: {
|
||||
rtp: '1200'
|
||||
}
|
||||
}
|
||||
rtp: '1200',
|
||||
},
|
||||
},
|
||||
}}
|
||||
// or other video props
|
||||
/>
|
||||
|
||||
@@ -118,6 +118,7 @@ export const srcAllPlatformList = [
|
||||
{
|
||||
description: '(mp4) big buck bunny With Ads',
|
||||
ad: {
|
||||
type: 'csai',
|
||||
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=',
|
||||
},
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
public struct AdParams {
|
||||
let type: String?
|
||||
let streamType: String?
|
||||
let adTagUrl: String?
|
||||
let adLanguage: String?
|
||||
let contentSourceId: String?
|
||||
let videoId: String?
|
||||
let assetKey: String?
|
||||
let format: String?
|
||||
let adTagParameters: [String: String]?
|
||||
let fallbackUri: String?
|
||||
|
||||
let json: NSDictionary?
|
||||
|
||||
var isCSAI: Bool { type == "csai" && adTagUrl != nil }
|
||||
var isDAI: Bool { type == "ssai" }
|
||||
var isDAIVod: Bool { type == "ssai" && streamType == "vod" }
|
||||
var isDAILive: Bool { type == "ssai" && streamType == "live" }
|
||||
|
||||
init(_ json: NSDictionary!) {
|
||||
guard json != nil else {
|
||||
self.json = nil
|
||||
type = nil
|
||||
streamType = nil
|
||||
adTagUrl = nil
|
||||
adLanguage = nil
|
||||
contentSourceId = nil
|
||||
videoId = nil
|
||||
assetKey = nil
|
||||
format = nil
|
||||
adTagParameters = nil
|
||||
fallbackUri = nil
|
||||
return
|
||||
}
|
||||
self.json = json
|
||||
type = json["type"] as? String
|
||||
streamType = json["streamType"] as? String
|
||||
adTagUrl = json["adTagUrl"] as? String
|
||||
adLanguage = json["adLanguage"] as? String
|
||||
contentSourceId = json["contentSourceId"] as? String
|
||||
videoId = json["videoId"] as? String
|
||||
assetKey = json["assetKey"] as? String
|
||||
format = json["format"] as? String
|
||||
fallbackUri = json["fallbackUri"] as? String
|
||||
|
||||
if let adTagParamsDict = json["adTagParameters"] as? [String: String] {
|
||||
adTagParameters = adTagParamsDict
|
||||
} else if let adTagParamsDict = json["adTagParameters"] as? NSDictionary {
|
||||
var params: [String: String] = [:]
|
||||
adTagParamsDict.enumerateKeysAndObjects { key, value, _ in
|
||||
if let keyString = key as? String, let valueString = value as? String {
|
||||
params[keyString] = valueString
|
||||
}
|
||||
}
|
||||
adTagParameters = params.isEmpty ? nil : params
|
||||
} else {
|
||||
adTagParameters = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Foundation
|
||||
import GoogleInteractiveMediaAds
|
||||
|
||||
class RCTIMAAdsManager: NSObject, IMAAdsLoaderDelegate, IMAAdsManagerDelegate, IMALinkOpenerDelegate {
|
||||
class RCTIMAAdsManager: NSObject, IMAAdsLoaderDelegate, IMAAdsManagerDelegate, IMALinkOpenerDelegate, IMAStreamManagerDelegate {
|
||||
private weak var _video: RCTVideo?
|
||||
private var _isPictureInPictureActive: () -> Bool
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
private var adsLoader: IMAAdsLoader!
|
||||
/* Main point of interaction with the SDK. Created by the SDK as the result of an ad request. */
|
||||
private var adsManager: IMAAdsManager!
|
||||
/* References the stream manager from the IMA DAI SDK after successfully loading the DAI stream. */
|
||||
private var streamManager: IMAStreamManager?
|
||||
/* Ad container view for DAI - stored to ensure proper z-ordering */
|
||||
private var daiAdContainerView: UIView?
|
||||
/* Picture-in-Picture proxy for DAI - stored to ensure proper Picture-in-Picture support */
|
||||
private var pipProxy: IMAPictureInPictureProxy?
|
||||
|
||||
init(video: RCTVideo!, isPictureInPictureActive: @escaping () -> Bool) {
|
||||
_video = video
|
||||
@@ -28,6 +34,18 @@
|
||||
adsLoader.delegate = self
|
||||
}
|
||||
|
||||
func setupDaiLoader() {
|
||||
guard let _video else { return }
|
||||
let settings = IMASettings()
|
||||
// Enable background playback only if PiP or playInBackground is enabled
|
||||
settings.enableBackgroundPlayback = _video.shouldEnableBackgroundPlayback()
|
||||
if let adLanguage = _video.getAdLanguage() {
|
||||
settings.language = adLanguage
|
||||
}
|
||||
adsLoader = IMAAdsLoader(settings: settings)
|
||||
adsLoader.delegate = self
|
||||
}
|
||||
|
||||
func requestAds() {
|
||||
guard let _video else { return }
|
||||
// fixes RCTVideo --> RCTIMAAdsManager --> IMAAdsLoader --> IMAAdDisplayContainer --> RCTVideo memory leak.
|
||||
@@ -54,8 +72,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
func requestDaiStream() {
|
||||
guard let _video else { return }
|
||||
// fixes RCTVideo --> RCTIMAAdsManager --> IMAAdsLoader --> IMAAdDisplayContainer --> RCTVideo memory leak.
|
||||
let adContainerView = UIView(frame: _video.bounds)
|
||||
adContainerView.backgroundColor = .clear
|
||||
_video.addSubview(adContainerView)
|
||||
// Store reference for later z-ordering management. DAI requires ad container to stay on top for proper ad UI visibility
|
||||
daiAdContainerView = adContainerView
|
||||
|
||||
// Create ad display container for ad rendering.
|
||||
let adDisplayContainer = IMAAdDisplayContainer(adContainer: adContainerView, viewController: _video.reactViewController())
|
||||
|
||||
let contentSourceID = _video.getContentSourceId()
|
||||
let videoID = _video.getVideoId()
|
||||
let imaVideoDisplay = _video.getIMAVideoDisplay()
|
||||
let assetKey = _video.getAssetKey()
|
||||
let adTagParameters = _video.getAdTagParameters()
|
||||
|
||||
if let pip = _video.getPip() {
|
||||
pipProxy = IMAPictureInPictureProxy(avPictureInPictureControllerDelegate: pip)
|
||||
} else {
|
||||
pipProxy = nil
|
||||
}
|
||||
|
||||
// Request DAI stream for VOD (Video On Demand)
|
||||
// Requires both contentSourceId (CMS ID) and videoId to identify the content
|
||||
if _video.isDAIVod() {
|
||||
let request = IMAVODStreamRequest(
|
||||
contentSourceID: contentSourceID!,
|
||||
videoID: videoID!,
|
||||
adDisplayContainer: adDisplayContainer,
|
||||
videoDisplay: imaVideoDisplay!,
|
||||
pictureInPictureProxy: pipProxy,
|
||||
userContext: nil
|
||||
)
|
||||
|
||||
// Apply adTagParameters if provided
|
||||
if let adTagParams = adTagParameters {
|
||||
request.adTagParameters = adTagParams
|
||||
}
|
||||
|
||||
adsLoader.requestStream(with: request)
|
||||
// Request DAI stream for live content
|
||||
// Uses assetKey to identify the live stream
|
||||
} else if _video.isDAILive() {
|
||||
let request = IMALiveStreamRequest(
|
||||
assetKey: assetKey!,
|
||||
adDisplayContainer: adDisplayContainer,
|
||||
videoDisplay: imaVideoDisplay!,
|
||||
pictureInPictureProxy: pipProxy,
|
||||
userContext: nil
|
||||
)
|
||||
|
||||
// Apply adTagParameters if provided
|
||||
if let adTagParams = adTagParameters {
|
||||
request.adTagParameters = adTagParams
|
||||
}
|
||||
|
||||
adsLoader.requestStream(with: request)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseAds() {
|
||||
guard let adsManager else { return }
|
||||
// CSAI
|
||||
if let adsManager {
|
||||
// Destroy AdsManager may be delayed for a few milliseconds
|
||||
// But what we want is it stopped producing sound immediately
|
||||
// Issue found on tvOS 17, or iOS if view detach & STARTED event happen at the same moment
|
||||
@@ -64,6 +145,15 @@
|
||||
adsManager.destroy()
|
||||
}
|
||||
|
||||
// DAI
|
||||
if let streamManager {
|
||||
streamManager.destroy()
|
||||
self.streamManager = nil
|
||||
|
||||
daiAdContainerView = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Getters
|
||||
|
||||
func getAdsLoader() -> IMAAdsLoader? {
|
||||
@@ -78,24 +168,71 @@
|
||||
|
||||
func adsLoader(_: IMAAdsLoader, adsLoadedWith adsLoadedData: IMAAdsLoadedData) {
|
||||
guard let _video else { return }
|
||||
// Grab the instance of the IMAAdsManager and set yourself as the delegate.
|
||||
adsManager = adsLoadedData.adsManager
|
||||
adsManager?.delegate = self
|
||||
|
||||
// Check if this is a stream manager (DAI) or ads manager (CSAI)
|
||||
// The adsLoadedData will contain either streamManager (for DAI) or adsManager (for CSAI)
|
||||
if let streamMgr = adsLoadedData.streamManager {
|
||||
streamManager = streamMgr
|
||||
streamManager?.delegate = self
|
||||
|
||||
// Ensure ad container stays on top when stream initializes
|
||||
// This is critical for DAI as ad overlays need to be visible
|
||||
if let adContainerView = daiAdContainerView {
|
||||
_video.bringSubviewToFront(adContainerView)
|
||||
}
|
||||
|
||||
// For DAI, the stream manager + IMAVideoDisplay automatically load content
|
||||
// No need to extract a URL - the stream manager handles playback directly
|
||||
// Just initialize and the player will start automatically
|
||||
self.streamManager?.initialize(with: nil)
|
||||
} else {
|
||||
// CSAI: Client-side ad insertion - ads are inserted by the client app
|
||||
self.adsManager = adsLoadedData.adsManager
|
||||
self.adsManager?.delegate = self
|
||||
|
||||
// Create ads rendering settings and tell the SDK to use the in-app browser.
|
||||
let adsRenderingSettings = IMAAdsRenderingSettings()
|
||||
adsRenderingSettings.linkOpenerDelegate = self
|
||||
adsRenderingSettings.linkOpenerPresentingController = _video.reactViewController()
|
||||
|
||||
adsManager.initialize(with: adsRenderingSettings)
|
||||
self.adsManager?.initialize(with: adsRenderingSettings)
|
||||
}
|
||||
}
|
||||
|
||||
func adsLoader(_: IMAAdsLoader, failedWith adErrorData: IMAAdLoadingErrorData) {
|
||||
guard let _video else { return }
|
||||
|
||||
if adErrorData.adError.message != nil {
|
||||
print("Error loading ads: " + adErrorData.adError.message!)
|
||||
}
|
||||
|
||||
_video?.setPaused(false)
|
||||
// CSAI
|
||||
if adsManager != nil {
|
||||
_video.setPaused(false)
|
||||
}
|
||||
|
||||
// DAI
|
||||
if streamManager != nil {
|
||||
_video.isSetSourceOngoing = false
|
||||
_video.applyNextSource()
|
||||
|
||||
// Handle DAI error by falling back to backup content if available
|
||||
// This provides resilience when DAI stream fails or is unavailable
|
||||
if let backupStreamUri = _video.getBackupStreamUri() {
|
||||
print("DAI stream error occurred, falling back to backup stream URI: \(backupStreamUri)")
|
||||
// Clean up DAI resources before switching to backup
|
||||
releaseAds()
|
||||
// Switch to backup stream - create a simple source dictionary with the URI
|
||||
// The backup stream typically contains the content without ads
|
||||
let backupSource: NSDictionary = [
|
||||
"uri": backupStreamUri,
|
||||
"isNetwork": true,
|
||||
]
|
||||
DispatchQueue.main.async {
|
||||
_video.setSrc(backupSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IMAAdsManagerDelegate
|
||||
@@ -167,6 +304,49 @@
|
||||
_video?.setPaused(false)
|
||||
}
|
||||
|
||||
// MARK: - IMAStreamManagerDelegate
|
||||
|
||||
func streamManager(_: IMAStreamManager, didReceive event: IMAAdEvent) {
|
||||
guard let _video else { return }
|
||||
|
||||
if _video.onReceiveAdEvent != nil {
|
||||
let type = convertEventToString(event: event.type)
|
||||
|
||||
if event.adData != nil {
|
||||
_video.onReceiveAdEvent?([
|
||||
"event": type,
|
||||
"data": event.adData ?? [String](),
|
||||
"target": _video.reactTag!,
|
||||
])
|
||||
} else {
|
||||
_video.onReceiveAdEvent?([
|
||||
"event": type,
|
||||
"target": _video.reactTag!,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamManager(_: IMAStreamManager, didReceive error: IMAAdError) {
|
||||
if error.message != nil {
|
||||
print("AdsManager error: " + error.message!)
|
||||
}
|
||||
|
||||
guard let _video else { return }
|
||||
|
||||
if _video.onReceiveAdEvent != nil {
|
||||
_video.onReceiveAdEvent?([
|
||||
"event": "ERROR",
|
||||
"data": [
|
||||
"message": error.message ?? "",
|
||||
"code": error.code,
|
||||
"type": error.type,
|
||||
],
|
||||
"target": _video.reactTag!,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IMALinkOpenerDelegate
|
||||
|
||||
func linkOpenerDidClose(inAppLink _: NSObject) {
|
||||
|
||||
@@ -87,6 +87,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
private var _imaAdsManager: RCTIMAAdsManager!
|
||||
/* Playhead used by the SDK to track content video progress and insert mid-rolls. */
|
||||
private var _contentPlayhead: IMAAVPlayerContentPlayhead?
|
||||
/* The reference of your video player for the IMA DAI SDK to monitor playback and handle timed metadata */
|
||||
private var _imaVideoDisplay: IMAAVPlayerVideoDisplay?
|
||||
#endif
|
||||
private var _didRequestAds = false
|
||||
private var _adPlaying = false
|
||||
@@ -438,7 +440,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
if currentTimeSecs >= 0 {
|
||||
#if USE_GOOGLE_IMA
|
||||
if !_didRequestAds && currentTimeSecs >= 0.0001 && _source?.adParams.adTagUrl != nil {
|
||||
if !_didRequestAds && currentTimeSecs >= 0.0001 && _source?.adParams.isCSAI == true {
|
||||
_imaAdsManager.requestAds()
|
||||
_didRequestAds = true
|
||||
}
|
||||
@@ -513,7 +515,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
applyNextSource()
|
||||
throw NSError(domain: "", code: 0, userInfo: nil)
|
||||
}
|
||||
|
||||
if let startPosition = _source?.startPosition {
|
||||
_startPosition = startPosition / 1000
|
||||
}
|
||||
@@ -621,10 +622,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
#if USE_GOOGLE_IMA
|
||||
if _source?.adParams.adTagUrl != nil {
|
||||
// Set up your content playhead and contentComplete callback.
|
||||
if _source?.adParams.isCSAI == true {
|
||||
_contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: _player!)
|
||||
|
||||
_imaAdsManager.setUpAdsLoader()
|
||||
}
|
||||
#endif
|
||||
@@ -644,6 +643,13 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
let initializeSource = {
|
||||
self._source = VideoSource(source)
|
||||
|
||||
#if USE_GOOGLE_IMA
|
||||
if self.isDaiSource() {
|
||||
self.handleDaiSource()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
if self._source?.uri == nil || self._source?.uri == "" {
|
||||
self._player?.replaceCurrentItem(with: nil)
|
||||
self.isSetSourceOngoing = false
|
||||
@@ -1162,10 +1168,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
func usePlayerViewController() {
|
||||
guard let _player, let _playerItem else { return }
|
||||
guard let _player else { return }
|
||||
|
||||
if _playerViewController == nil {
|
||||
_playerViewController = createPlayerViewController(player: _player, withPlayerItem: _playerItem)
|
||||
_playerViewController = createPlayerViewController(player: _player)
|
||||
}
|
||||
// to prevent video from being animated when resizeMode is 'cover'
|
||||
// resize mode must be set before subview is added
|
||||
@@ -1189,7 +1195,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
_playerObserver.playerViewController = _playerViewController
|
||||
}
|
||||
|
||||
func createPlayerViewController(player: AVPlayer, withPlayerItem _: AVPlayerItem) -> RCTVideoPlayerViewController {
|
||||
func createPlayerViewController(player: AVPlayer) -> RCTVideoPlayerViewController {
|
||||
let viewController = RCTVideoPlayerViewController()
|
||||
viewController.showsPlaybackControls = self._controls
|
||||
#if !os(tvOS)
|
||||
@@ -1354,6 +1360,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
return _source?.adParams.adTagUrl
|
||||
}
|
||||
|
||||
func getPip() -> RCTPictureInPicture? {
|
||||
initPictureinPicture()
|
||||
return _pip
|
||||
}
|
||||
|
||||
/// Returns whether background playback should be enabled for IMA DAI SDK.
|
||||
/// Used to configure `IMASettings.enableBackgroundPlayback` which is required for DAI streams
|
||||
func shouldEnableBackgroundPlayback() -> Bool {
|
||||
return _playInBackground || _enterPictureInPictureOnLeave
|
||||
}
|
||||
|
||||
#if USE_GOOGLE_IMA
|
||||
func getContentPlayhead() -> IMAAVPlayerContentPlayhead? {
|
||||
return _contentPlayhead
|
||||
@@ -1618,7 +1635,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
"" : (_playerItem.error! as NSError).localizedFailureReason) ?? "",
|
||||
"localizedRecoverySuggestion": ((_playerItem.error! as NSError).localizedRecoverySuggestion == nil ?
|
||||
"" : (_playerItem.error! as NSError).localizedRecoverySuggestion) ?? "",
|
||||
"domain": (_playerItem.error as! NSError).domain,
|
||||
"domain": (_playerItem.error as NSError?)?.domain ?? "",
|
||||
],
|
||||
"target": reactTag as Any,
|
||||
]
|
||||
@@ -1864,7 +1881,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
@objc
|
||||
func exitPictureInPicture() {
|
||||
guard isPictureInPictureActive() else { return }
|
||||
|
||||
_pip?.exitPictureInPicture()
|
||||
if _enterPictureInPictureOnLeave {
|
||||
initPictureinPicture()
|
||||
@@ -1877,3 +1893,186 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
@objc
|
||||
func setOnClick(_: Any) {}
|
||||
}
|
||||
|
||||
// MARK: - DAI Support
|
||||
|
||||
#if USE_GOOGLE_IMA
|
||||
extension RCTVideo: IMAAVPlayerVideoDisplayDelegate {
|
||||
/// Checks if the current source is a DAI (Dynamic Ad Insertion) request.
|
||||
///
|
||||
/// Returns `true` if either:
|
||||
/// - VOD request: both `contentSourceId` and `videoId` are present
|
||||
func isDaiSource() -> Bool {
|
||||
return _source?.adParams.isDAI ?? false
|
||||
}
|
||||
|
||||
func isDAIVod() -> Bool {
|
||||
return _source?.adParams.isDAIVod ?? false
|
||||
}
|
||||
|
||||
func isDAILive() -> Bool {
|
||||
return _source?.adParams.isDAILive ?? false
|
||||
}
|
||||
|
||||
func getContentSourceId() -> String? {
|
||||
return _source?.adParams.contentSourceId
|
||||
}
|
||||
|
||||
func getAssetKey() -> String? {
|
||||
return _source?.adParams.assetKey
|
||||
}
|
||||
|
||||
func getVideoId() -> String? {
|
||||
return _source?.adParams.videoId
|
||||
}
|
||||
|
||||
func getAdTagParameters() -> [String: String]? {
|
||||
return _source?.adParams.adTagParameters
|
||||
}
|
||||
|
||||
func getBackupStreamUri() -> String? {
|
||||
return _source?.adParams.fallbackUri
|
||||
}
|
||||
|
||||
/// Returns the IMA video display instance used for DAI playback.
|
||||
func getIMAVideoDisplay() -> IMAVideoDisplay? {
|
||||
return _imaVideoDisplay
|
||||
}
|
||||
|
||||
/// Sets up DAI (Dynamic Ad Insertion) by preparing the player, setting up the DAI loader, and requesting the stream.
|
||||
/// This method must be called on the main thread as it performs UI operations.
|
||||
func handleDaiSource() {
|
||||
DispatchQueue.main.sync {
|
||||
removePlayerLayer()
|
||||
|
||||
_playerObserver.player = nil
|
||||
_playerObserver.playerItem = nil
|
||||
_drmManager = nil
|
||||
|
||||
preparePlayerForDai()
|
||||
|
||||
_imaVideoDisplay = IMAAVPlayerVideoDisplay(avPlayer: _player!)
|
||||
_imaVideoDisplay?.playerVideoDisplayDelegate = self
|
||||
|
||||
if _controls {
|
||||
usePlayerViewController()
|
||||
} else {
|
||||
usePlayerLayer()
|
||||
}
|
||||
|
||||
_imaAdsManager.setupDaiLoader()
|
||||
_imaAdsManager.requestDaiStream()
|
||||
|
||||
_videoLoadStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepares the AVPlayer for DAI playback by initializing or resetting the player configuration.
|
||||
func preparePlayerForDai() {
|
||||
if !isSetSourceOngoing {
|
||||
DebugLog("setSrc has been canceled last step")
|
||||
return
|
||||
}
|
||||
|
||||
if _player == nil {
|
||||
_player = AVPlayer()
|
||||
ReactNativeVideoManager.shared.onInstanceCreated(id: instanceId, player: _player as Any)
|
||||
}
|
||||
|
||||
_player!.pause()
|
||||
_player!.replaceCurrentItem(with: nil)
|
||||
_player!.actionAtItemEnd = .none
|
||||
|
||||
if #available(iOS 10.0, *) {
|
||||
_player!.automaticallyWaitsToMinimizeStalling = _automaticallyWaitsToMinimizeStalling
|
||||
}
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
if _playInBackground {
|
||||
_player!.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible
|
||||
} else {
|
||||
_player!.audiovisualBackgroundPlaybackPolicy = .automatic
|
||||
}
|
||||
}
|
||||
|
||||
_playerObserver.player = _player
|
||||
}
|
||||
|
||||
/// Sets up the player item with all item-specific configurations for DAI playback.
|
||||
///
|
||||
/// This method should be called after `preparePlayerForDai()` when a player item becomes available.
|
||||
///
|
||||
/// - Parameter playerItem: The AVPlayerItem to configure and set on the player
|
||||
func setupDaiPlayerItem(_ playerItem: AVPlayerItem) async throws {
|
||||
if !isSetSourceOngoing {
|
||||
DebugLog("setSrc has been canceled last step")
|
||||
return
|
||||
}
|
||||
|
||||
guard let _player else {
|
||||
throw NSError(domain: "RCTVideo", code: -1, userInfo: [NSLocalizedDescriptionKey: "Player not initialized. Call preparePlayerForDai() first."])
|
||||
}
|
||||
|
||||
_playerItem = playerItem
|
||||
_playerObserver.playerItem = _playerItem
|
||||
|
||||
setPreferredForwardBufferDuration(_preferredForwardBufferDuration)
|
||||
setPlaybackRange(playerItem, withCropStart: _source?.cropStart, withCropEnd: _source?.cropEnd)
|
||||
setFilter(_filterName)
|
||||
if let maxBitRate = _maxBitRate {
|
||||
_playerItem?.preferredPeakBitRate = Double(maxBitRate)
|
||||
}
|
||||
|
||||
_player.replaceCurrentItem(with: playerItem)
|
||||
|
||||
#if !os(tvOS) && !os(visionOS)
|
||||
if #available(iOS 16.0, macCatalyst 18.0, *) {
|
||||
self._playerViewController?.allowsVideoFrameAnalysis = false
|
||||
self._playerViewController?.allowsVideoFrameAnalysis = true
|
||||
}
|
||||
#endif
|
||||
|
||||
if _showNotificationControls {
|
||||
NowPlayingInfoCenterManager.shared.registerPlayer(player: _player)
|
||||
} else {
|
||||
NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
|
||||
}
|
||||
|
||||
applyModifiers()
|
||||
|
||||
isSetSourceOngoing = false
|
||||
applyNextSource()
|
||||
}
|
||||
|
||||
/// Called when the IMA video display loads a player item for DAI playback.
|
||||
///
|
||||
/// This delegate method is invoked by the IMA SDK when the DAI stream player item is ready.
|
||||
/// It sets up the player item and applies all necessary modifiers.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - playerVideoDisplay: The IMA video display instance
|
||||
/// - playerItem: The AVPlayerItem loaded by the IMA SDK
|
||||
func playerVideoDisplay(_: IMAAVPlayerVideoDisplay,
|
||||
didLoad playerItem: AVPlayerItem) {
|
||||
RCTVideoUtils.delay { [weak self] in
|
||||
do {
|
||||
guard let self else { throw NSError(domain: "", code: 0, userInfo: nil) }
|
||||
|
||||
try await self.setupDaiPlayerItem(playerItem)
|
||||
} catch {
|
||||
DebugLog("An error occurred: \(error.localizedDescription)")
|
||||
|
||||
if let self {
|
||||
self.onVideoError?(["error": error.localizedDescription])
|
||||
self.isSetSourceOngoing = false
|
||||
self.applyNextSource()
|
||||
|
||||
if let player = self._player {
|
||||
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -78,14 +78,9 @@ RCT_EXTERN_METHOD(enterPictureInPictureCmd : (nonnull NSNumber*)reactTag)
|
||||
RCT_EXTERN_METHOD(exitPictureInPictureCmd : (nonnull NSNumber*)reactTag)
|
||||
RCT_EXTERN_METHOD(setSourceCmd : (nonnull NSNumber*)reactTag source : (NSDictionary*)source)
|
||||
|
||||
RCT_EXTERN_METHOD(save
|
||||
: (nonnull NSNumber*)reactTag options
|
||||
: (NSDictionary*)options resolve
|
||||
: (RCTPromiseResolveBlock)resolve reject
|
||||
: (RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(getCurrentPosition
|
||||
: (nonnull NSNumber*)reactTag resolve
|
||||
: (RCTPromiseResolveBlock)resolve reject
|
||||
: (RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(save : (nonnull NSNumber*)reactTag options : (NSDictionary*)options resolve : (RCTPromiseResolveBlock)
|
||||
resolve reject : (RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(getCurrentPosition : (nonnull NSNumber*)reactTag resolve : (RCTPromiseResolveBlock)
|
||||
resolve reject : (RCTPromiseRejectBlock)reject)
|
||||
|
||||
@end
|
||||
|
||||
@@ -53,7 +53,57 @@ import type {
|
||||
ReactVideoProps,
|
||||
CmcdData,
|
||||
ReactVideoSource,
|
||||
AdConfig,
|
||||
AdConfigDAIVod,
|
||||
AdConfigDAILive,
|
||||
} from './types';
|
||||
import type {ISO639_1} from './types/language';
|
||||
import type {AdsConfig} from './specs/VideoNativeComponent';
|
||||
|
||||
function normalizeAdConfig(
|
||||
ad: AdConfig | undefined,
|
||||
legacyAdTagUrl: string | undefined,
|
||||
legacyAdLanguage: ISO639_1 | undefined,
|
||||
): AdsConfig | undefined {
|
||||
if (ad) {
|
||||
// Default to 'csai' for backward compatibility with old API (ad without type)
|
||||
const adType = 'type' in ad ? ad.type : 'csai';
|
||||
|
||||
if (adType === 'ssai') {
|
||||
const daiAd = ad as AdConfigDAIVod | AdConfigDAILive;
|
||||
return {
|
||||
type: 'ssai',
|
||||
streamType: daiAd.streamType,
|
||||
adLanguage: daiAd.adLanguage,
|
||||
contentSourceId:
|
||||
'contentSourceId' in daiAd ? daiAd.contentSourceId : undefined,
|
||||
videoId: 'videoId' in daiAd ? daiAd.videoId : undefined,
|
||||
assetKey: 'assetKey' in daiAd ? daiAd.assetKey : undefined,
|
||||
format: daiAd.format,
|
||||
adTagParameters: daiAd.adTagParameters,
|
||||
fallbackUri: daiAd.fallbackUri,
|
||||
};
|
||||
}
|
||||
|
||||
// CSAI (explicit or default for backward compatibility)
|
||||
return {
|
||||
type: 'csai',
|
||||
adTagUrl: 'adTagUrl' in ad ? ad.adTagUrl : undefined,
|
||||
adLanguage: ad.adLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy props at <Video> component level
|
||||
if (legacyAdTagUrl || legacyAdLanguage) {
|
||||
return {
|
||||
type: 'csai',
|
||||
adTagUrl: legacyAdTagUrl,
|
||||
adLanguage: legacyAdLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
(
|
||||
@@ -166,7 +216,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
if (uri && uri.match(/^\//)) {
|
||||
uri = `file://${uri}`;
|
||||
}
|
||||
if (!uri) {
|
||||
if (!uri && _source.ad?.type !== 'ssai') {
|
||||
console.log('Trying to load empty source');
|
||||
}
|
||||
const isNetwork = !!(uri && uri.match(/^(rtp|rtsp|http|https):/));
|
||||
@@ -222,11 +272,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
const selectedContentStartTime =
|
||||
_source.contentStartTime || contentStartTime;
|
||||
|
||||
const _ad =
|
||||
_source.ad ||
|
||||
(adTagUrl || adLanguage
|
||||
? {adTagUrl: adTagUrl, adLanguage: adLanguage}
|
||||
: undefined);
|
||||
const _ad = normalizeAdConfig(_source.ad, adTagUrl, adLanguage);
|
||||
|
||||
const _minLoadRetryCount =
|
||||
_source.minLoadRetryCount || minLoadRetryCount;
|
||||
|
||||
@@ -27,8 +27,16 @@ type VideoMetadata = Readonly<{
|
||||
}>;
|
||||
|
||||
export type AdsConfig = Readonly<{
|
||||
type?: string;
|
||||
streamType?: string;
|
||||
adTagUrl?: string;
|
||||
adLanguage?: string;
|
||||
contentSourceId?: string;
|
||||
videoId?: string;
|
||||
assetKey?: string;
|
||||
format?: string;
|
||||
adTagParameters?: Record<string, string>;
|
||||
fallbackUri?: string;
|
||||
}>;
|
||||
|
||||
export type VideoSrc = Readonly<{
|
||||
|
||||
@@ -77,11 +77,45 @@ export enum DRMType {
|
||||
FAIRPLAY = 'fairplay',
|
||||
}
|
||||
|
||||
export type AdConfig = Readonly<{
|
||||
adTagUrl?: string;
|
||||
export type DaiFormat = 'hls' | 'dash';
|
||||
export type DaiStreamType = 'vod' | 'live';
|
||||
|
||||
type AdConfigBase = Readonly<{
|
||||
adLanguage?: ISO639_1;
|
||||
}>;
|
||||
|
||||
export type AdConfigCSAI = AdConfigBase &
|
||||
Readonly<{
|
||||
type: 'csai';
|
||||
adTagUrl: string;
|
||||
}>;
|
||||
|
||||
type AdConfigDAIBase = AdConfigBase &
|
||||
Readonly<{
|
||||
type: 'ssai';
|
||||
streamType: DaiStreamType;
|
||||
format?: DaiFormat;
|
||||
adTagParameters?: Record<string, string>;
|
||||
fallbackUri?: string;
|
||||
}>;
|
||||
|
||||
export type AdConfigDAIVod = AdConfigDAIBase &
|
||||
Readonly<{
|
||||
streamType: 'vod';
|
||||
contentSourceId: string;
|
||||
videoId: string;
|
||||
}>;
|
||||
|
||||
export type AdConfigDAILive = AdConfigDAIBase &
|
||||
Readonly<{
|
||||
streamType: 'live';
|
||||
assetKey: string;
|
||||
}>;
|
||||
|
||||
export type AdConfigDAI = AdConfigDAIVod | AdConfigDAILive;
|
||||
|
||||
export type AdConfig = AdConfigCSAI | AdConfigDAI;
|
||||
|
||||
export type Drm = Readonly<{
|
||||
type?: DRMType;
|
||||
licenseServer?: string;
|
||||
|
||||
Reference in New Issue
Block a user